@agentlayer.tech/wallet 0.1.19 → 0.1.21

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentlayertech/agent-wallet-plugin",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "OpenClaw plugin bridge for the AgentLayer wallet runtime.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN ../../../LICENSE",
package/README.md CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  [![npm version](https://img.shields.io/npm/v/%40agentlayer.tech%2Fwallet)](https://www.npmjs.com/package/@agentlayer.tech/wallet)
5
5
  [![npm downloads](https://img.shields.io/npm/dm/%40agentlayer.tech%2Fwallet)](https://www.npmjs.com/package/@agentlayer.tech/wallet)
6
+ [![docs](https://img.shields.io/badge/docs-agent--layer.tech-blue)](https://docs.agent-layer.tech/)
6
7
  [![license](https://img.shields.io/github/license/lopushok9/Agent-Layer)](https://github.com/lopushok9/Agent-Layer/blob/main/LICENSE)
7
8
 
8
9
  ```bash
@@ -82,9 +83,13 @@ wallet status
82
83
  wallet doctor
83
84
  wallet hermes install --yes
84
85
  wallet update --yes
86
+ wallet update --yes --dry-run
85
87
  wallet rollback
86
88
  ```
87
89
 
90
+ `wallet update --yes` now delegates to the latest published npm package and reuses shared Python and Node dependency snapshots when they have not changed, so frequent upgrades do not need to rebuild every runtime dependency from scratch.
91
+ Use `wallet update --yes --dry-run` to inspect the target runtime version and whether Python/Node dependency snapshots will be reused or rebuilt before switching `current`.
92
+
88
93
  ## Native OpenClaw plugin installs
89
94
 
90
95
  Use ClawHub when you want the plugin itself to be installed through OpenClaw:
@@ -115,6 +120,111 @@ sh ./setup.sh
115
120
 
116
121
  If you want the installer to finish the OpenClaw plugin wiring in the same pass, provide the runtime secrets before running it:
117
122
 
123
+ ## Wallet capabilities through external services
124
+
125
+ AgentLayer keeps keys, approvals, and signing local, but the wallet can still operate through a set of registered provider-backed tools. These tools are exposed through the OpenClaw wallet plugin as explicit service integrations rather than raw shell access, config editing, or backend switching.
126
+
127
+ ### x402 paid APIs
128
+
129
+ The x402 bundle turns the wallet into a buyer for metered APIs and paid HTTP endpoints:
130
+
131
+ - `x402_search_services` - search x402-paid services through discovery providers such as CDP Bazaar and Agentic Market without spending funds.
132
+ - `x402_get_service_details` - resolve one discovered service or resource into a normalized detail payload before attempting payment.
133
+ - `x402_preview_request` - make an unpaid request, detect `402 Payment Required`, and summarize payment terms and supported payment options.
134
+ - `x402_pay_request` - prepare or execute the paid retry through the active wallet backend. The current flow executes the Solana exact-buyer path and keeps EVM as prepare-only.
135
+
136
+ This gives the wallet a direct bridge from service discovery to paid API consumption while preserving approval-token checks before execution.
137
+
138
+ ### LI.FI cross-chain routing
139
+
140
+ The LI.FI bundle covers discovery, quote inspection, transfer tracking, and routed execution across Solana, Ethereum, and Base:
141
+
142
+ - `get_lifi_supported_chains` - list the chains currently allowed for LI.FI routing in the wallet surface.
143
+ - `get_lifi_quote` - fetch a read-only cross-chain quote before any execution planning.
144
+ - `get_lifi_transfer_status` - inspect a routed transfer by transaction hash or LI.FI step id.
145
+ - `swap_solana_lifi_cross_chain_tokens` - preview, prepare, or execute a Solana-origin cross-chain route into Ethereum or Base.
146
+ - `swap_evm_lifi_cross_chain_tokens` - preview, prepare, or execute an EVM-origin cross-chain route across Ethereum, Base, and Solana when LI.FI returns a route.
147
+
148
+ ### Jupiter trading and yield
149
+
150
+ On Solana, Jupiter-backed tools cover market pricing, swaps, and Jupiter Earn vault flows:
151
+
152
+ - `get_solana_token_prices` - fetch current Solana token pricing through Jupiter.
153
+ - `swap_solana_tokens` - preview, prepare, or execute a Jupiter-routed Solana token swap.
154
+ - `get_jupiter_earn_tokens` - list Jupiter Earn vault assets currently supported on mainnet.
155
+ - `get_jupiter_earn_positions` - inspect wallet positions in Jupiter Earn vaults.
156
+ - `get_jupiter_earn_earnings` - fetch earnings for one or more Jupiter Earn positions.
157
+ - `jupiter_earn_deposit` - preview, prepare, or execute a Jupiter Earn deposit.
158
+ - `jupiter_earn_withdraw` - preview, prepare, or execute a Jupiter Earn withdrawal.
159
+
160
+ ### Houdini private payouts
161
+
162
+ For privacy-preserving Solana payout flows, the wallet exposes a Houdini-backed bundle:
163
+
164
+ - `swap_solana_privately` - create a preview or approved private payout through Houdini routing. The current MVP supports same-token flows such as `SOL -> SOL` and `USDC -> USDC`.
165
+ - `continue_solana_private_swap` - continue a previously created Houdini order and submit the local funding transfer to the returned deposit address.
166
+ - `get_solana_private_swap_status` - check Houdini status for an existing private payout.
167
+ - `list_pending_solana_private_swaps` - list cached pending Houdini orders for the current OpenClaw session.
168
+
169
+ This flow is intentionally optimized for `preview -> execute` rather than adding a no-op prepare step.
170
+
171
+ ### Kamino lending
172
+
173
+ Kamino integration gives the wallet a structured Solana lending surface:
174
+
175
+ - `get_kamino_lend_markets` - list Kamino lending markets available on Solana mainnet.
176
+ - `get_kamino_lend_market_reserves` - inspect reserve metrics for one Kamino market.
177
+ - `get_kamino_lend_user_obligations` - inspect the wallet's obligations inside a Kamino market.
178
+ - `get_kamino_lend_user_rewards` - fetch the wallet's Kamino rewards summary.
179
+ - `kamino_lend_deposit` - preview, prepare, or execute a lending deposit.
180
+ - `kamino_lend_withdraw` - preview, prepare, or execute a lending withdrawal.
181
+ - `kamino_lend_borrow` - preview, prepare, or execute a borrow.
182
+ - `kamino_lend_repay` - preview, prepare, or execute a repay.
183
+
184
+ ### Flash Trade perps
185
+
186
+ Flash Trade integration adds a managed perpetuals surface on Solana mainnet:
187
+
188
+ - `get_flash_trade_markets` - list currently available Flash Trade markets.
189
+ - `get_flash_trade_positions` - inspect the wallet's open Flash Trade positions.
190
+ - `flash_trade_open_position` - preview, prepare, or execute a perp position open.
191
+ - `flash_trade_close_position` - preview, prepare, or execute a perp position close.
192
+
193
+ ### Bags launch and fee-share
194
+
195
+ Bags-backed tools cover token launch and post-launch fee analytics:
196
+
197
+ - `get_bags_claimable_positions` - inspect claimable Bags fee-share positions for a Solana wallet.
198
+ - `get_bags_fee_analytics` - fetch analytics and optional claim history for a launched token.
199
+ - `claim_bags_fees` - preview, prepare, or execute a Bags fee-share claim.
200
+ - `launch_bags_token` - preview, prepare, or execute a Bags token launch with fee-share configuration.
201
+
202
+ ### EVM DeFi integrations
203
+
204
+ The EVM wallet surface includes named DeFi integrations on `ethereum` and `base`, without exposing arbitrary calldata execution.
205
+
206
+ Velora swap routing:
207
+
208
+ - `get_evm_swap_quote` - fetch a read-only EVM swap quote.
209
+ - `swap_evm_tokens` - preview, prepare, or execute a routed EVM token swap.
210
+
211
+ Aave V3:
212
+
213
+ - `get_evm_aave_account` - inspect the wallet's Aave account state.
214
+ - `get_evm_aave_reserves` - fetch reserve data for supported Aave markets.
215
+ - `get_evm_aave_positions` - inspect the wallet's open Aave positions.
216
+ - `manage_evm_aave_position` - preview, prepare, or execute Aave position changes through the managed wallet flow.
217
+
218
+ Lido:
219
+
220
+ - `get_evm_lido_overview` - fetch Lido protocol overview data relevant to the wallet surface.
221
+ - `get_evm_lido_positions` - inspect the wallet's Lido positions.
222
+ - `get_evm_lido_withdrawal_requests` - inspect outstanding Lido withdrawal requests.
223
+ - `manage_evm_lido_position` - preview, prepare, or execute a Lido staking position change.
224
+ - `manage_evm_lido_withdrawal` - preview, prepare, or execute a Lido withdrawal management action.
225
+
226
+ Across these service-backed flows, read operations remain directly callable, while write operations stay behind preview, explicit intent, and host-issued approval tokens before execution.
227
+
118
228
  Solana:
119
229
 
120
230
  ```bash
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "openclaw-agent-wallet"
7
- version = "0.1.19"
7
+ version = "0.1.21"
8
8
  description = "Plugin-friendly wallet backend for OpenClaw agents"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -350,14 +350,22 @@ function variantToSide(sideVariant) {
350
350
  return String(sideVariant ?? "");
351
351
  }
352
352
 
353
+ function safeTokenSymbol(poolConfig, mintPk) {
354
+ try {
355
+ return poolConfig.getTokenFromMintPk(mintPk)?.symbol ?? null;
356
+ } catch {
357
+ return null;
358
+ }
359
+ }
360
+
353
361
  function buildMarketSnapshot(poolConfig, marketConfig, deprecated = false) {
354
- const targetToken = poolConfig.getTokenFromMintPk(marketConfig.targetMint);
355
- const collateralToken = poolConfig.getTokenFromMintPk(marketConfig.collateralMint);
362
+ const targetSymbol = safeTokenSymbol(poolConfig, marketConfig.targetMint);
363
+ const collateralSymbol = safeTokenSymbol(poolConfig, marketConfig.collateralMint);
356
364
  return {
357
365
  pool_name: poolConfig.poolName,
358
- symbol: targetToken.symbol,
359
- market_symbol: targetToken.symbol,
360
- collateral_symbol: collateralToken.symbol,
366
+ symbol: targetSymbol,
367
+ market_symbol: targetSymbol,
368
+ collateral_symbol: collateralSymbol,
361
369
  side: variantToSide(marketConfig.side),
362
370
  market_id: marketConfig.marketId,
363
371
  market_address: marketConfig.marketAccount.toBase58(),
@@ -374,14 +382,16 @@ function buildMarketSnapshot(poolConfig, marketConfig, deprecated = false) {
374
382
 
375
383
  function buildPositionSnapshot(poolConfig, positionAccount) {
376
384
  const marketConfig = poolConfig.getMarketConfigByPk(positionAccount.market);
377
- const targetToken = poolConfig.getTokenFromMintPk(marketConfig.targetMint);
378
- const collateralToken = poolConfig.getTokenFromMintPk(marketConfig.collateralMint);
385
+ const targetSymbol = marketConfig ? safeTokenSymbol(poolConfig, marketConfig.targetMint) : null;
386
+ const collateralSymbol = marketConfig
387
+ ? safeTokenSymbol(poolConfig, marketConfig.collateralMint)
388
+ : null;
379
389
  return {
380
390
  pool_name: poolConfig.poolName,
381
- symbol: targetToken.symbol,
382
- market_symbol: targetToken.symbol,
383
- collateral_symbol: collateralToken.symbol,
384
- side: variantToSide(marketConfig.side),
391
+ symbol: targetSymbol,
392
+ market_symbol: targetSymbol,
393
+ collateral_symbol: collateralSymbol,
394
+ side: variantToSide(marketConfig?.side),
385
395
  is_active: Boolean(positionAccount.isActive),
386
396
  position_address: positionAccount.pubkey.toBase58(),
387
397
  market_address: positionAccount.market.toBase58(),
@@ -3,8 +3,10 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ import hashlib
6
7
  import json
7
8
  import os
9
+ import platform
8
10
  import shutil
9
11
  import subprocess
10
12
  import sys
@@ -114,6 +116,21 @@ def _resolve_sealed_keys_path() -> Path:
114
116
  return _resolve_openclaw_home() / "sealed_keys.json"
115
117
 
116
118
 
119
+ def _runtime_base_for(runtime_root: Path) -> Path:
120
+ resolved = runtime_root.expanduser().resolve()
121
+ if resolved.parent.name == "releases":
122
+ return resolved.parent.parent
123
+ return resolved.parent
124
+
125
+
126
+ def _shared_runtime_root(runtime_root: Path) -> Path:
127
+ return _runtime_base_for(runtime_root) / "shared"
128
+
129
+
130
+ def _shared_dependency_links_supported() -> bool:
131
+ return os.name != "nt"
132
+
133
+
117
134
  def _atomic_write_text(path: Path, content: str, *, mode: int = 0o600) -> None:
118
135
  path.parent.mkdir(parents=True, exist_ok=True)
119
136
  fd, temp_path = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent))
@@ -139,6 +156,21 @@ def _chmod_if_exists(path: Path, mode: int = 0o600) -> None:
139
156
  return
140
157
 
141
158
 
159
+ def _sha256_text(parts: list[str]) -> str:
160
+ digest = hashlib.sha256()
161
+ for part in parts:
162
+ digest.update(part.encode("utf-8"))
163
+ digest.update(b"\0")
164
+ return digest.hexdigest()
165
+
166
+
167
+ def _file_text_or_empty(path: Path) -> str:
168
+ try:
169
+ return path.read_text(encoding="utf-8")
170
+ except FileNotFoundError:
171
+ return ""
172
+
173
+
142
174
  def build_parser() -> argparse.ArgumentParser:
143
175
  parser = argparse.ArgumentParser(description=__doc__)
144
176
  parser.add_argument("--config-path", default=str(_default_config_path()))
@@ -272,6 +304,14 @@ def _sync_runtime_tree(source_root: Path, runtime_root: Path) -> dict[str, objec
272
304
  def _ensure_env_file(env_path: Path, env_example_path: Path) -> bool:
273
305
  if env_path.exists():
274
306
  return False
307
+ if not env_example_path.exists():
308
+ source_candidate = _package_root() / ".env.example"
309
+ if source_candidate.exists():
310
+ env_example_path = source_candidate
311
+ else:
312
+ raise SystemExit(
313
+ f"Missing env example template at '{env_example_path}'."
314
+ )
275
315
  env_path.parent.mkdir(parents=True, exist_ok=True)
276
316
  shutil.copyfile(env_example_path, env_path)
277
317
  _chmod_if_exists(env_path, 0o600)
@@ -366,8 +406,95 @@ def _ensure_python_wrapper(venv_path: Path) -> Path:
366
406
  return wrapper
367
407
 
368
408
 
369
- def _ensure_python_runtime(venv_path: Path, package_root: Path) -> tuple[Path, bool]:
409
+ def _python_runtime_fingerprint(package_root: Path, python_bin: Path) -> str:
410
+ version = f"{sys.version_info.major}.{sys.version_info.minor}"
411
+ return _sha256_text(
412
+ [
413
+ f"python-bin:{python_bin}",
414
+ f"python-version:{version}",
415
+ f"platform:{platform.system()}",
416
+ f"machine:{platform.machine()}",
417
+ _file_text_or_empty(package_root / "pyproject.toml"),
418
+ ]
419
+ )[:24]
420
+
421
+
422
+ def _python_runtime_plan(
423
+ venv_path: Path,
424
+ package_root: Path,
425
+ runtime_root: Path,
426
+ ) -> dict[str, object]:
427
+ if _shared_dependency_links_supported():
428
+ fingerprint = _python_runtime_fingerprint(package_root, Path(sys.executable))
429
+ shared_root = _shared_runtime_root(runtime_root) / "python" / fingerprint
430
+ shared_venv_path = shared_root / "venv"
431
+ shared_wrapper = _venv_python_wrapper(shared_venv_path)
432
+ return {
433
+ "shared": True,
434
+ "fingerprint": fingerprint,
435
+ "shared_root": str(shared_root),
436
+ "venv_path": str(shared_venv_path),
437
+ "release_link_path": str(venv_path),
438
+ "python_bin": str(venv_path / shared_wrapper.relative_to(shared_venv_path)),
439
+ "action": "reuse" if shared_venv_path.exists() else "create",
440
+ "exists": shared_venv_path.exists(),
441
+ }
442
+ return {
443
+ "shared": False,
444
+ "fingerprint": None,
445
+ "shared_root": None,
446
+ "venv_path": str(venv_path),
447
+ "release_link_path": str(venv_path),
448
+ "python_bin": str(_venv_python_wrapper(venv_path)),
449
+ "action": "install",
450
+ "exists": _venv_python(venv_path).exists(),
451
+ }
452
+
453
+
454
+ def _replace_with_directory_symlink(link_path: Path, target_path: Path) -> None:
455
+ target_resolved = target_path.resolve()
456
+ if link_path.is_symlink():
457
+ existing_target = link_path.resolve()
458
+ if existing_target == target_resolved:
459
+ return
460
+ link_path.unlink()
461
+ elif link_path.exists():
462
+ if link_path.is_dir():
463
+ shutil.rmtree(link_path)
464
+ else:
465
+ link_path.unlink()
466
+ link_path.parent.mkdir(parents=True, exist_ok=True)
467
+ link_path.symlink_to(target_resolved, target_is_directory=True)
468
+
469
+
470
+ def _ensure_python_runtime(
471
+ venv_path: Path,
472
+ package_root: Path,
473
+ runtime_root: Path,
474
+ ) -> tuple[Path, bool, dict[str, object]]:
370
475
  created = False
476
+ plan = _python_runtime_plan(venv_path, package_root, runtime_root)
477
+ if bool(plan["shared"]):
478
+ shared_root = Path(str(plan["shared_root"]))
479
+ shared_venv_path = Path(str(plan["venv_path"]))
480
+ python_bin = _venv_python(shared_venv_path)
481
+ if not python_bin.exists():
482
+ venv.EnvBuilder(with_pip=True).create(shared_venv_path)
483
+ created = True
484
+ subprocess.run(
485
+ [str(python_bin), "-m", "pip", "install", "-e", str(package_root)],
486
+ check=True,
487
+ )
488
+ shared_wrapper = _ensure_python_wrapper(shared_venv_path)
489
+ _replace_with_directory_symlink(venv_path, shared_venv_path)
490
+ plan["action"] = "create" if created else "reuse"
491
+ plan["exists"] = True
492
+ return (
493
+ venv_path / shared_wrapper.relative_to(shared_venv_path),
494
+ created,
495
+ plan,
496
+ )
497
+
371
498
  python_bin = _venv_python(venv_path)
372
499
  if not python_bin.exists():
373
500
  venv.EnvBuilder(with_pip=True).create(venv_path)
@@ -377,10 +504,79 @@ def _ensure_python_runtime(venv_path: Path, package_root: Path) -> tuple[Path, b
377
504
  [str(python_bin), "-m", "pip", "install", "-e", str(package_root)],
378
505
  check=True,
379
506
  )
380
- return _ensure_python_wrapper(venv_path), created
507
+ return (
508
+ _ensure_python_wrapper(venv_path),
509
+ created,
510
+ plan,
511
+ )
381
512
 
382
513
 
383
- def _ensure_node_runtime(npm_bin: str, project_root: Path) -> dict[str, object]:
514
+ def _node_version() -> str:
515
+ result = subprocess.run(
516
+ ["node", "--version"],
517
+ capture_output=True,
518
+ text=True,
519
+ check=True,
520
+ )
521
+ return result.stdout.strip()
522
+
523
+
524
+ def _node_runtime_fingerprint(project_root: Path) -> str:
525
+ return _sha256_text(
526
+ [
527
+ f"node-version:{_node_version()}",
528
+ f"platform:{platform.system()}",
529
+ f"machine:{platform.machine()}",
530
+ _file_text_or_empty(project_root / "package.json"),
531
+ _file_text_or_empty(project_root / "package-lock.json"),
532
+ ]
533
+ )[:24]
534
+
535
+
536
+ def _node_runtime_plan(project_root: Path, runtime_root: Path) -> dict[str, object]:
537
+ package_json = project_root / "package.json"
538
+ package_lock = project_root / "package-lock.json"
539
+ command = ["npm", "ci"] if package_lock.exists() else ["npm", "install"]
540
+ if _shared_dependency_links_supported():
541
+ fingerprint = _node_runtime_fingerprint(project_root)
542
+ shared_project_root = _shared_runtime_root(runtime_root) / "node" / project_root.name / fingerprint
543
+ shared_node_modules = shared_project_root / "node_modules"
544
+ return {
545
+ "project_root": str(project_root),
546
+ "package_json": str(package_json),
547
+ "package_lock": str(package_lock) if package_lock.exists() else None,
548
+ "command": command,
549
+ "shared": True,
550
+ "fingerprint": fingerprint,
551
+ "shared_root": str(shared_project_root),
552
+ "node_modules_path": str(shared_node_modules),
553
+ "release_link_path": str(project_root / "node_modules"),
554
+ "action": "reuse" if shared_node_modules.exists() else "create",
555
+ "exists": shared_node_modules.exists(),
556
+ }
557
+ return {
558
+ "project_root": str(project_root),
559
+ "package_json": str(package_json),
560
+ "package_lock": str(package_lock) if package_lock.exists() else None,
561
+ "command": command,
562
+ "shared": False,
563
+ "fingerprint": None,
564
+ "shared_root": None,
565
+ "node_modules_path": str(project_root / "node_modules"),
566
+ "release_link_path": str(project_root / "node_modules"),
567
+ "action": "install",
568
+ "exists": (project_root / "node_modules").exists(),
569
+ }
570
+
571
+
572
+ def _copy_if_exists(source: Path, target: Path) -> None:
573
+ if not source.exists():
574
+ return
575
+ target.parent.mkdir(parents=True, exist_ok=True)
576
+ shutil.copy2(source, target)
577
+
578
+
579
+ def _ensure_node_runtime(npm_bin: str, project_root: Path, runtime_root: Path) -> dict[str, object]:
384
580
  package_json = project_root / "package.json"
385
581
  if not package_json.exists():
386
582
  raise SystemExit(f"Missing package.json for Node runtime at '{project_root}'.")
@@ -394,14 +590,30 @@ def _ensure_node_runtime(npm_bin: str, project_root: Path) -> dict[str, object]:
394
590
  env["NPM_CONFIG_CACHE"] = cache_dir
395
591
  env["npm_config_cache"] = cache_dir
396
592
  Path(cache_dir).expanduser().mkdir(parents=True, exist_ok=True)
593
+ plan = _node_runtime_plan(project_root, runtime_root)
594
+ if bool(plan["shared"]):
595
+ shared_project_root = Path(str(plan["shared_root"]))
596
+ shared_node_modules = Path(str(plan["node_modules_path"]))
597
+ created = False
598
+ if not shared_node_modules.exists():
599
+ shared_project_root.mkdir(parents=True, exist_ok=True)
600
+ _copy_if_exists(package_json, shared_project_root / "package.json")
601
+ _copy_if_exists(package_lock, shared_project_root / "package-lock.json")
602
+ _copy_if_exists(project_root / ".npmrc", shared_project_root / ".npmrc")
603
+ subprocess.run(command, cwd=shared_project_root, check=True, env=env)
604
+ created = True
605
+ _replace_with_directory_symlink(project_root / "node_modules", shared_node_modules)
606
+ plan["cache_dir"] = cache_dir
607
+ plan["created"] = created
608
+ plan["action"] = "create" if created else "reuse"
609
+ plan["exists"] = True
610
+ return plan
611
+
397
612
  subprocess.run(command, cwd=project_root, check=True, env=env)
398
- return {
399
- "project_root": str(project_root),
400
- "package_json": str(package_json),
401
- "package_lock": str(package_lock) if package_lock.exists() else None,
402
- "command": command,
403
- "cache_dir": cache_dir,
404
- }
613
+ plan["cache_dir"] = cache_dir
614
+ plan["created"] = True
615
+ plan["exists"] = True
616
+ return plan
405
617
 
406
618
 
407
619
  def _pending_env_names() -> list[str]:
@@ -526,13 +738,28 @@ def main() -> None:
526
738
  python_bin = Path(sys.executable)
527
739
  venv_created = False
528
740
  existing_wrapper = _venv_python_wrapper(venv_path)
741
+ python_runtime: dict[str, object] = {
742
+ "shared": False,
743
+ "fingerprint": None,
744
+ "shared_root": None,
745
+ "venv_path": str(venv_path),
746
+ "release_link_path": str(venv_path),
747
+ "python_bin": str(_venv_python_wrapper(venv_path)),
748
+ "action": "skipped",
749
+ "exists": existing_wrapper.exists(),
750
+ }
529
751
  if args.skip_python_setup and args.install_from_runtime and existing_wrapper.exists():
530
752
  python_bin = existing_wrapper
531
753
  elif not args.skip_python_setup:
532
754
  if not args.dry_run:
533
- python_bin, venv_created = _ensure_python_runtime(venv_path, package_root)
755
+ python_bin, venv_created, python_runtime = _ensure_python_runtime(
756
+ venv_path,
757
+ package_root,
758
+ runtime_root,
759
+ )
534
760
  else:
535
- python_bin = _venv_python_wrapper(venv_path)
761
+ python_runtime = _python_runtime_plan(venv_path, package_root, runtime_root)
762
+ python_bin = Path(str(python_runtime["python_bin"]))
536
763
 
537
764
  node_runtime = {
538
765
  "skipped": bool(args.skip_node_setup),
@@ -548,17 +775,22 @@ def main() -> None:
548
775
  if args.dry_run:
549
776
  node_runtime["projects"] = [
550
777
  {
551
- "project_root": str(project_root),
778
+ **_node_runtime_plan(project_root, runtime_root),
552
779
  "command": [
553
780
  args.npm_bin,
554
781
  "ci" if (project_root / "package-lock.json").exists() else "install",
555
782
  ],
783
+ "cache_dir": (
784
+ os.environ.get("OPENCLAW_AGENT_WALLET_NPM_CACHE")
785
+ or str(_resolve_openclaw_home() / "npm-cache")
786
+ ),
787
+ "created": False,
556
788
  }
557
789
  for project_root in node_projects
558
790
  ]
559
791
  else:
560
792
  node_runtime["projects"] = [
561
- _ensure_node_runtime(args.npm_bin, project_root)
793
+ _ensure_node_runtime(args.npm_bin, project_root, runtime_root)
562
794
  for project_root in node_projects
563
795
  ]
564
796
 
@@ -600,6 +832,7 @@ def main() -> None:
600
832
  "install_from_runtime": bool(args.install_from_runtime),
601
833
  "python_bin": str(python_bin),
602
834
  "venv_created": venv_created,
835
+ "python_runtime": python_runtime,
603
836
  "node_runtime": node_runtime,
604
837
  "runtime_sync": runtime_sync,
605
838
  "configured": configured,
@@ -13,6 +13,8 @@ const setupPath = path.join(packageRoot, "setup.sh");
13
13
  const packageJsonPath = path.join(packageRoot, "package.json");
14
14
  const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
15
15
  const packageVersion = packageJson.version;
16
+ const UPDATE_CLI_PATH_ENV = "OPENCLAW_AGENT_WALLET_UPDATE_CLI_PATH";
17
+ const UPDATE_PACKAGE_SPEC_ENV = "OPENCLAW_AGENT_WALLET_UPDATE_PACKAGE_SPEC";
16
18
 
17
19
  function printHelp() {
18
20
  console.log(`openclaw-agent-wallet
@@ -37,6 +39,7 @@ Examples:
37
39
  npx @agentlayer.tech/wallet hermes install --yes
38
40
  npx @agentlayer.tech/wallet install --backend none
39
41
  npx @agentlayer.tech/wallet update --yes
42
+ npx @agentlayer.tech/wallet update --yes --dry-run
40
43
  npx @agentlayer.tech/wallet status
41
44
 
42
45
  The installer writes a versioned runtime under:
@@ -46,7 +49,23 @@ After a successful install it switches:
46
49
  ~/.openclaw/agent-wallet-runtime/current
47
50
 
48
51
  Wallet files and sealed secrets remain under OPENCLAW_HOME and are not replaced
49
- by updates.`);
52
+ by updates. The update command fetches the latest published npm package and
53
+ reuses shared dependency snapshots when possible.`);
54
+ }
55
+
56
+ function primaryBinCommand(pkg = packageJson) {
57
+ const bin = pkg?.bin;
58
+ if (!bin) return "wallet";
59
+ if (typeof bin === "string") {
60
+ const packageName = String(pkg?.name || "").trim();
61
+ if (packageName) {
62
+ const parts = packageName.split("/");
63
+ return parts[parts.length - 1] || "wallet";
64
+ }
65
+ return "wallet";
66
+ }
67
+ const names = Object.keys(bin);
68
+ return names[0] || "wallet";
50
69
  }
51
70
 
52
71
  function expandHome(value) {
@@ -207,6 +226,80 @@ function activeVersion(env = process.env) {
207
226
  return path.basename(path.resolve(path.dirname(current), link));
208
227
  }
209
228
 
229
+ function listDirectories(rootPath) {
230
+ try {
231
+ return fs
232
+ .readdirSync(rootPath, { withFileTypes: true })
233
+ .filter((entry) => entry.isDirectory())
234
+ .map((entry) => entry.name)
235
+ .sort();
236
+ } catch (error) {
237
+ if (error?.code === "ENOENT") return [];
238
+ throw error;
239
+ }
240
+ }
241
+
242
+ function activePythonRuntimeInfo(env = process.env) {
243
+ const currentRoot = resolvedCurrentRuntimeRoot(env);
244
+ if (!currentRoot) return null;
245
+ const linkPath = path.join(currentRoot, "agent-wallet", ".runtime-venv");
246
+ const exists = fs.existsSync(linkPath);
247
+ const symlinkTarget = readLinkOrNull(linkPath);
248
+ const resolvedTarget = exists ? path.resolve(path.dirname(linkPath), symlinkTarget || ".") : null;
249
+ return {
250
+ link_path: linkPath,
251
+ exists,
252
+ symlink: Boolean(symlinkTarget),
253
+ target: symlinkTarget || null,
254
+ resolved_target: resolvedTarget,
255
+ shared: Boolean(resolvedTarget && resolvedTarget.includes(`${path.sep}shared${path.sep}python${path.sep}`)),
256
+ };
257
+ }
258
+
259
+ function activeNodeRuntimeInfo(env = process.env) {
260
+ const currentRoot = resolvedCurrentRuntimeRoot(env);
261
+ if (!currentRoot) return [];
262
+ const projects = [
263
+ path.join(currentRoot, "wdk-btc-wallet"),
264
+ path.join(currentRoot, "wdk-evm-wallet"),
265
+ path.join(currentRoot, "agent-wallet", "scripts", "flash-sdk-bridge"),
266
+ ];
267
+ return projects
268
+ .filter((projectRoot) => fs.existsSync(path.join(projectRoot, "package.json")))
269
+ .map((projectRoot) => {
270
+ const linkPath = path.join(projectRoot, "node_modules");
271
+ const exists = fs.existsSync(linkPath);
272
+ const symlinkTarget = readLinkOrNull(linkPath);
273
+ const resolvedTarget = exists ? path.resolve(path.dirname(linkPath), symlinkTarget || ".") : null;
274
+ return {
275
+ project_root: projectRoot,
276
+ project_name: path.basename(projectRoot),
277
+ link_path: linkPath,
278
+ exists,
279
+ symlink: Boolean(symlinkTarget),
280
+ target: symlinkTarget || null,
281
+ resolved_target: resolvedTarget,
282
+ shared: Boolean(resolvedTarget && resolvedTarget.includes(`${path.sep}shared${path.sep}node${path.sep}`)),
283
+ };
284
+ });
285
+ }
286
+
287
+ function sharedSnapshotInventory(env = process.env) {
288
+ const runtimeBase = resolveRuntimeBase(env);
289
+ const sharedRoot = path.join(runtimeBase, "shared");
290
+ const pythonRoot = path.join(sharedRoot, "python");
291
+ const nodeRoot = path.join(sharedRoot, "node");
292
+ const nodeProjects = listDirectories(nodeRoot).map((projectName) => ({
293
+ project_name: projectName,
294
+ snapshots: listDirectories(path.join(nodeRoot, projectName)),
295
+ }));
296
+ return {
297
+ shared_root: sharedRoot,
298
+ python_snapshots: listDirectories(pythonRoot),
299
+ node_projects: nodeProjects,
300
+ };
301
+ }
302
+
210
303
  function switchSymlink(linkPath, targetPath) {
211
304
  const absoluteTarget = path.resolve(targetPath);
212
305
  if (!fs.existsSync(absoluteTarget)) {
@@ -216,7 +309,7 @@ function switchSymlink(linkPath, targetPath) {
216
309
  fs.mkdirSync(path.dirname(linkPath), { recursive: true });
217
310
  const tempLink = `${linkPath}.tmp-${process.pid}`;
218
311
  try {
219
- fs.rmSync(tempLink, { force: true, recursive: false });
312
+ fs.rmSync(tempLink, { force: true, recursive: true });
220
313
  } catch {
221
314
  // ignored
222
315
  }
@@ -225,7 +318,7 @@ function switchSymlink(linkPath, targetPath) {
225
318
  try {
226
319
  const existing = fs.lstatSync(linkPath);
227
320
  if (!existing.isSymbolicLink()) {
228
- fs.rmSync(tempLink, { force: true });
321
+ fs.rmSync(tempLink, { force: true, recursive: true });
229
322
  throw new Error(`${linkPath} exists and is not a symlink. Refusing to replace it.`);
230
323
  }
231
324
  } catch (error) {
@@ -268,6 +361,60 @@ function withoutCliOnlyArgs(args) {
268
361
  return output;
269
362
  }
270
363
 
364
+ function extractTrailingJson(text) {
365
+ const raw = String(text || "");
366
+ const newlineStart = raw.lastIndexOf("\n{");
367
+ const start = newlineStart >= 0 ? newlineStart + 1 : raw.indexOf("{");
368
+ if (start < 0) {
369
+ throw new Error("Could not find JSON payload in command output.");
370
+ }
371
+ return JSON.parse(raw.slice(start));
372
+ }
373
+
374
+ function pathVersionFromRuntimeRoot(runtimeRoot) {
375
+ if (!runtimeRoot) return null;
376
+ const normalized = path.resolve(String(runtimeRoot));
377
+ if (path.basename(path.dirname(normalized)) !== "releases") return null;
378
+ return path.basename(normalized);
379
+ }
380
+
381
+ function resolveCliPackageMeta(cliPath) {
382
+ try {
383
+ const root = path.resolve(path.dirname(cliPath), "..");
384
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
385
+ return {
386
+ name: String(pkg.name || packageJson.name),
387
+ version: String(pkg.version || ""),
388
+ root,
389
+ };
390
+ } catch {
391
+ return {
392
+ name: packageJson.name,
393
+ version: "",
394
+ root: "",
395
+ };
396
+ }
397
+ }
398
+
399
+ function summarizeDependencyPlan(payload) {
400
+ const python = payload?.python_runtime && typeof payload.python_runtime === "object"
401
+ ? {
402
+ action: payload.python_runtime.action || "unknown",
403
+ shared: Boolean(payload.python_runtime.shared),
404
+ fingerprint: payload.python_runtime.fingerprint || null,
405
+ }
406
+ : null;
407
+ const nodeProjects = Array.isArray(payload?.node_runtime?.projects)
408
+ ? payload.node_runtime.projects.map((project) => ({
409
+ project_root: project.project_root,
410
+ action: project.action || "unknown",
411
+ shared: Boolean(project.shared),
412
+ fingerprint: project.fingerprint || null,
413
+ }))
414
+ : [];
415
+ return { python, node_projects: nodeProjects };
416
+ }
417
+
271
418
  function token() {
272
419
  return crypto.randomBytes(32).toString("base64url");
273
420
  }
@@ -442,24 +589,25 @@ function runDoctor() {
442
589
  return missing.length === 0 ? 0 : 1;
443
590
  }
444
591
 
445
- function runStatus() {
446
- console.log(
447
- JSON.stringify(
448
- {
449
- ok: true,
450
- package_name: packageJson.name,
451
- package_version: packageVersion,
452
- openclaw_home: resolveOpenclawHome(),
453
- runtime_base: resolveRuntimeBase(),
454
- current_runtime: currentRuntimePath(),
455
- previous_runtime: readLinkOrNull(previousRuntimePath()),
456
- active_version: activeVersion(),
457
- available_releases: listReleases(),
458
- },
459
- null,
460
- 2,
461
- ),
462
- );
592
+ function runStatus(args = []) {
593
+ const payload = {
594
+ ok: true,
595
+ package_name: packageJson.name,
596
+ package_version: packageVersion,
597
+ openclaw_home: resolveOpenclawHome(),
598
+ runtime_base: resolveRuntimeBase(),
599
+ current_runtime: currentRuntimePath(),
600
+ previous_runtime: readLinkOrNull(previousRuntimePath()),
601
+ active_version: activeVersion(),
602
+ available_releases: listReleases(),
603
+ };
604
+ if (hasFlag(args, "--verbose")) {
605
+ payload.verbose = true;
606
+ payload.active_python_runtime = activePythonRuntimeInfo();
607
+ payload.active_node_runtimes = activeNodeRuntimeInfo();
608
+ payload.shared_snapshot_inventory = sharedSnapshotInventory();
609
+ }
610
+ console.log(JSON.stringify(payload, null, 2));
463
611
  return 0;
464
612
  }
465
613
 
@@ -467,14 +615,19 @@ function buildInstallerEnv(args) {
467
615
  const env = { ...process.env };
468
616
  const sealedKeysPath = path.join(resolveOpenclawHome(env), "sealed_keys.json");
469
617
  const sealedKeysExist = fs.existsSync(sealedKeysPath);
618
+ const dryRun = hasFlag(args, "--dry-run");
470
619
  if (!env.AGENT_WALLET_BOOT_KEY) {
471
- const existingBootKey = resolveBootKeyFromFile(env) || currentBootKey(env);
620
+ const existingBootKey =
621
+ resolveBootKeyFromFile(env) ||
622
+ readTextIfExists(defaultBootKeyFile(env)).trim() ||
623
+ currentBootKey(env);
472
624
  if (existingBootKey) {
473
625
  env.AGENT_WALLET_BOOT_KEY = existingBootKey;
474
626
  }
475
627
  }
476
628
 
477
629
  const shouldGenerateSecrets =
630
+ !dryRun &&
478
631
  !hasFlag(args, "--no-auto-secrets") &&
479
632
  (hasFlag(args, "--yes") || !env.AGENT_WALLET_BOOT_KEY);
480
633
 
@@ -554,6 +707,14 @@ function runInstall(args, { commandName = "install" } = {}) {
554
707
  });
555
708
  }
556
709
 
710
+ const pythonInfo = activePythonRuntimeInfo(env);
711
+ const nodeInfo = activeNodeRuntimeInfo(env)
712
+ .map((item) => `${item.project_name}:${item.shared ? "shared" : item.exists ? "local" : "missing"}`)
713
+ .join(", ");
714
+ console.error(
715
+ `Update summary: version=${packageVersion} active=${activeVersion(env) || packageVersion} python=${pythonInfo?.shared ? "shared" : pythonInfo?.exists ? "local" : "missing"} node=[${nodeInfo}]`,
716
+ );
717
+
557
718
  console.error(
558
719
  JSON.stringify(
559
720
  {
@@ -572,6 +733,119 @@ function runInstall(args, { commandName = "install" } = {}) {
572
733
  return 0;
573
734
  }
574
735
 
736
+ function resolveUpdatePackageSpec(env = process.env) {
737
+ const explicit = String(env[UPDATE_PACKAGE_SPEC_ENV] || "").trim();
738
+ if (explicit) return explicit;
739
+ return `${packageJson.name}@latest`;
740
+ }
741
+
742
+ function runDelegatedInstallForUpdate(args, { captureOutput = false } = {}) {
743
+ const localCliPath = String(process.env[UPDATE_CLI_PATH_ENV] || "").trim();
744
+ if (localCliPath) {
745
+ const meta = resolveCliPackageMeta(localCliPath);
746
+ const result = spawnSync("node", [localCliPath, "install", ...args], {
747
+ cwd: packageRoot,
748
+ stdio: captureOutput ? "pipe" : "inherit",
749
+ encoding: captureOutput ? "utf8" : undefined,
750
+ env: process.env,
751
+ });
752
+ return {
753
+ result,
754
+ delegated_via: "cli_path",
755
+ target_package_spec: meta.name || packageJson.name,
756
+ target_version_hint: meta.version || null,
757
+ };
758
+ }
759
+
760
+ const npmBin = commandPath("npm");
761
+ if (!npmBin) {
762
+ throw new Error("npm is required for `wallet update`. Install npm or run `npx @agentlayer.tech/wallet install --yes`.");
763
+ }
764
+
765
+ const packageSpec = resolveUpdatePackageSpec();
766
+ const binCommand = primaryBinCommand();
767
+ const result = spawnSync(
768
+ npmBin,
769
+ ["exec", "--yes", `--package=${packageSpec}`, binCommand, "--", "install", ...args],
770
+ {
771
+ cwd: packageRoot,
772
+ stdio: captureOutput ? "pipe" : "inherit",
773
+ encoding: captureOutput ? "utf8" : undefined,
774
+ env: process.env,
775
+ },
776
+ );
777
+ return {
778
+ result,
779
+ delegated_via: "npm_exec",
780
+ target_package_spec: packageSpec,
781
+ target_version_hint: null,
782
+ };
783
+ }
784
+
785
+ function runUpdate(args) {
786
+ const dryRun = hasFlag(args, "--dry-run");
787
+ if (dryRun) {
788
+ try {
789
+ const delegated = runDelegatedInstallForUpdate(args, { captureOutput: true });
790
+ const { result } = delegated;
791
+ if (result.error) {
792
+ console.error(result.error.message);
793
+ return 1;
794
+ }
795
+ if ((result.status ?? 1) !== 0) {
796
+ const stderr = String(result.stderr || "").trim();
797
+ const stdout = String(result.stdout || "").trim();
798
+ if (stderr) process.stderr.write(`${stderr}\n`);
799
+ if (stdout) process.stdout.write(`${stdout}\n`);
800
+ return result.status ?? 1;
801
+ }
802
+ const payload = extractTrailingJson(result.stdout || "");
803
+ const targetVersion =
804
+ delegated.target_version_hint ||
805
+ pathVersionFromRuntimeRoot(payload.runtime_root) ||
806
+ null;
807
+ console.log(
808
+ JSON.stringify(
809
+ {
810
+ ok: true,
811
+ command: "update",
812
+ dry_run: true,
813
+ current_version: activeVersion(),
814
+ installed_cli_version: packageVersion,
815
+ target_package_spec: delegated.target_package_spec,
816
+ target_version: targetVersion,
817
+ delegated_via: delegated.delegated_via,
818
+ runtime_base: resolveRuntimeBase(),
819
+ current_runtime: currentRuntimePath(),
820
+ target_runtime_root: payload.runtime_root,
821
+ dependency_plan: summarizeDependencyPlan(payload),
822
+ install_plan: payload,
823
+ },
824
+ null,
825
+ 2,
826
+ ),
827
+ );
828
+ return 0;
829
+ } catch (error) {
830
+ console.error(error.message);
831
+ return 1;
832
+ }
833
+ }
834
+
835
+ let delegated;
836
+ try {
837
+ delegated = runDelegatedInstallForUpdate(args, { captureOutput: false });
838
+ } catch (error) {
839
+ console.error(error.message);
840
+ return 1;
841
+ }
842
+ if (delegated.result.error) {
843
+ console.error(delegated.result.error.message);
844
+ return 1;
845
+ }
846
+ return delegated.result.status ?? 1;
847
+ }
848
+
575
849
  function runRollback(args) {
576
850
  const requested = parseFlagValue(args, "--to");
577
851
  const current = activeVersion();
@@ -755,7 +1029,7 @@ if (command === "doctor") {
755
1029
  }
756
1030
 
757
1031
  if (command === "status") {
758
- process.exit(runStatus());
1032
+ process.exit(runStatus(args.slice(1)));
759
1033
  }
760
1034
 
761
1035
  if (command === "install" || command === "setup") {
@@ -763,7 +1037,7 @@ if (command === "install" || command === "setup") {
763
1037
  }
764
1038
 
765
1039
  if (command === "update") {
766
- process.exit(runInstall(args.slice(1), { commandName: "update" }));
1040
+ process.exit(runUpdate(args.slice(1)));
767
1041
  }
768
1042
 
769
1043
  if (command === "rollback") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentlayer.tech/wallet",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "NPM installer for the OpenClaw Agent Wallet local runtime.",
5
5
  "type": "module",
6
6
  "repository": {