@agentlayer.tech/wallet 0.1.72 → 0.1.74

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.
@@ -2,7 +2,7 @@
2
2
  "id": "agent-wallet",
3
3
  "name": "Agent Wallet",
4
4
  "description": "Official OpenClaw plugin bridge for the agent-wallet backends, including Solana, local BTC, and local EVM.",
5
- "version": "0.1.72",
5
+ "version": "0.1.74",
6
6
  "contracts": {
7
7
  "tools": [
8
8
  "agentlayer_autonomous_approve",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentlayertech/agent-wallet-plugin",
3
- "version": "0.1.72",
3
+ "version": "0.1.74",
4
4
  "description": "OpenClaw plugin bridge for the AgentLayer wallet runtime.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN ../../../LICENSE",
package/CHANGELOG.md CHANGED
@@ -2,6 +2,64 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Added a guarded npm beta-promotion workflow that acceptance-tests the exact
6
+ registry artifact, verifies its integrity, rejects downgrades, serializes with
7
+ publish jobs, and restores the previous `latest` tag if verification fails.
8
+
9
+ - Added a stable per-home identity and verified PID ownership record for the
10
+ local EVM daemon. Restarts now signal only the confirmed listener, while a
11
+ bounded single-listener compatibility path upgrades pre-identity daemons.
12
+
13
+ - Disabled implicit Argon2id-to-HKDF rewrites during normal wallet reads.
14
+ Opt-in envelope migration now creates and verifies an Argon2id rollback
15
+ backup, restores the original on write failure, and refuses stale restores
16
+ after logical secret contents change.
17
+
18
+ - Persisted the verified boot-key keystore backend under each OpenClaw home so
19
+ `auto` selection remains stable across updates and desktop session changes.
20
+ Explicit backend overrides still work, and temporary fallback use does not
21
+ overwrite the pinned backend.
22
+
23
+ - Preserved upgrades from pre-resolver runtimes by ordering the legacy Node
24
+ fallback as keystore, current runtime env, then legacy files. Installer output
25
+ now reports the non-secret boot-key source, and CI covers conflicting legacy
26
+ sources, staged verification failures, editor migration, and EVM wrong-home
27
+ daemon recovery.
28
+
29
+ - Replaced automatic editor reinstallation after every runtime update with a
30
+ narrow migration of stale runtime-owned symlinks and Hermes env paths. Codex
31
+ and Claude MCP source manifests are now immutable; only user-owned Claude
32
+ cache copies may receive a custom `OPENCLAW_HOME` pin. The versioned runtime
33
+ now includes the previously omitted `claude-code/` bridge.
34
+
35
+ - Staged normal installs under hidden `releases/.staging-*` directories and
36
+ verify them before switching `current`. Failed candidates are moved to
37
+ `.failed-*`, excluded from `available_releases`, and recorded in an update
38
+ journal; on failure, existing `current` and `previous` pointers remain untouched.
39
+
40
+ - Centralized installer boot-key selection in the active Python runtime. When
41
+ sealed secrets exist, conflicting env, keystore, and legacy-file candidates
42
+ are tested against `sealed_keys.json`; a stale higher-priority value can no
43
+ longer mask the key that actually decrypts the bundle. Older runtimes retain
44
+ the existing JavaScript fallback for upgrade compatibility.
45
+
46
+ - Removed a test-only `OPENCLAW_HOME` pin that leaked into the published Claude
47
+ Code MCP manifest. Installer regression tests now isolate every editor home,
48
+ assert that tracked plugin sources remain unchanged, and inspect the packed
49
+ npm MCP manifests for temporary or user-specific absolute paths.
50
+
51
+ ## v0.1.73 - 2026-07-09
52
+
53
+ - Fixed CLI installer/update boot-key precedence when recovering an existing
54
+ hardened install. `buildInstallerEnv()` now resolves the boot key in the same
55
+ order as the runtime itself: explicit env first, then keystore, and only then
56
+ plaintext fallback files. This prevents a stale
57
+ `~/.openclaw/agent-wallet-runtime/boot-key` from overriding a correct key in
58
+ the keystore and breaking `wallet install` / `wallet update` even though the
59
+ active runtime can still decrypt `sealed_keys.json`.
60
+ - `bin/openclaw-agent-wallet.mjs`
61
+ - `agent-wallet/tests/smoke_cli_install_prefers_keystore_over_stale_boot_file.py`
62
+
5
63
  ## v0.1.72 - 2026-07-09
6
64
 
7
65
  - Fixed a `0.1.71` installer regression where `wallet update --yes` could fail
package/RELEASING.md CHANGED
@@ -166,10 +166,29 @@ The GitHub Releases page should contain a new prerelease named
166
166
 
167
167
  ### Promote a beta to stable
168
168
 
169
- Once a beta checks out, ship the same version as a stable `latest` release by
170
- following the **Stable Release** steps with the promoted version number (e.g.
171
- `0.1.11`). The update notice that runtimes show to agents reads the `latest`
172
- dist-tag, so users are only nudged toward promoted releases, never betas.
169
+ Once a beta checks out, promote the exact published tarball instead of rebuilding
170
+ it under a different version:
171
+
172
+ 1. Open the `promote npm beta` workflow in GitHub Actions.
173
+ 2. Enter the current beta version, for example `0.1.11-beta.1`.
174
+ 3. Enable the confirmation input and run the workflow.
175
+
176
+ The workflow verifies that the version is the current `beta`, refuses to move
177
+ `latest` backward, records its registry integrity, and installs that exact npm
178
+ artifact into an isolated OpenClaw home. Only after acceptance succeeds does it
179
+ move `latest`; if post-write verification fails, the previous `latest` is
180
+ restored. The existing GitHub prerelease is then marked stable.
181
+
182
+ Because npm packages are immutable, the promoted artifact keeps its prerelease
183
+ version string while becoming the stable `latest` channel. This guarantees that
184
+ production receives the same bytes tested on beta.
185
+
186
+ The workflow requires a repository secret named `NPM_PROMOTION_TOKEN`. npm OIDC
187
+ trusted publishing only authorizes publish operations, not `dist-tag` changes,
188
+ so this must be a granular token scoped to `@agentlayer.tech/wallet` with package
189
+ write access and bypass-2FA enabled. Do not reuse a broad account token.
190
+ Configure required reviewers and main-branch deployment protection on the
191
+ `npm-production` GitHub environment before using promotion in production.
173
192
 
174
193
  ## What Ships
175
194
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.72
1
+ 0.1.74
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Keep in sync with package.json, pyproject.toml, and the npm installer version.
4
4
  # scripts/check_release_version.mjs enforces this on release.
5
- __version__ = "0.1.72"
5
+ __version__ = "0.1.74"
6
6
 
7
7
  __all__ = [
8
8
  "config",
@@ -16,7 +16,12 @@ from agent_wallet.config import (
16
16
  settings,
17
17
  )
18
18
  from agent_wallet.file_ops import atomic_write_text
19
- from agent_wallet.keystore import BOOT_KEY_ITEM, PlaintextFileStore, resolve_keystore
19
+ from agent_wallet.keystore import (
20
+ BOOT_KEY_ITEM,
21
+ PlaintextFileStore,
22
+ record_keystore_backend,
23
+ resolve_keystore,
24
+ )
20
25
 
21
26
  _ENV_LINE_PREFIX = "AGENT_WALLET_BOOT_KEY="
22
27
 
@@ -129,6 +134,8 @@ def migrate_boot_key_to_keystore() -> dict:
129
134
  # Do not sweep into a plaintext fallback: that offers no at-rest improvement,
130
135
  # and the user explicitly chose to stay on the current file in that case.
131
136
  if isinstance(store, PlaintextFileStore):
137
+ if read_boot_key_from_keystore():
138
+ record_keystore_backend(store)
132
139
  return {"migrated": False, "backend": store.backend_id, "swept_env_files": 0,
133
140
  "removed_boot_key_file": False, "reason": "no-os-keystore"}
134
141
 
@@ -145,12 +152,13 @@ def migrate_boot_key_to_keystore() -> dict:
145
152
  return {"migrated": False, "backend": store.backend_id, "swept_env_files": 0,
146
153
  "removed_boot_key_file": False, "reason": f"keystore-set-failed: {exc}"}
147
154
  # Verify-before-delete.
148
- if read_boot_key_from_keystore() != legacy_key:
155
+ if store.get(BOOT_KEY_ITEM) != legacy_key:
149
156
  return {"migrated": False, "backend": store.backend_id, "swept_env_files": 0,
150
157
  "removed_boot_key_file": False, "reason": "verify-failed"}
151
158
  authoritative = legacy_key
152
159
  first_time = True
153
160
 
161
+ record_keystore_backend(store)
154
162
  swept, removed = _sweep_plaintext(home, authoritative)
155
163
  migrated = first_time or swept > 0 or removed
156
164
  return {"migrated": migrated, "backend": store.backend_id, "swept_env_files": swept,
@@ -8,8 +8,8 @@ wallet files. See BOOT_KEY_KEYCHAIN_ARCHITECTURE.md.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
- from agent_wallet.config import read_boot_key_from_keystore, resolve_boot_key
12
- from agent_wallet.keystore import BOOT_KEY_ITEM, resolve_keystore
11
+ from agent_wallet.config import clear_secret_caches, resolve_boot_key
12
+ from agent_wallet.keystore import BOOT_KEY_ITEM, record_keystore_backend, resolve_keystore
13
13
  from agent_wallet.wallet_layer.base import WalletBackendError
14
14
 
15
15
  _EXPORT_WARNING = (
@@ -35,11 +35,13 @@ def import_boot_key(value: str) -> dict:
35
35
  raise WalletBackendError("A non-empty boot key is required for import.")
36
36
  store = resolve_keystore()
37
37
  store.set(BOOT_KEY_ITEM, key)
38
- if read_boot_key_from_keystore() != key:
38
+ if store.get(BOOT_KEY_ITEM) != key:
39
39
  raise WalletBackendError(
40
40
  f"Boot key import could not be verified from the {store.backend_id} keystore."
41
41
  )
42
- return {"imported": True, "backend": store.backend_id}
42
+ state = record_keystore_backend(store)
43
+ clear_secret_caches()
44
+ return {"imported": True, "backend": store.backend_id, "backend_state": state}
43
45
 
44
46
 
45
47
  def export_warning() -> str:
@@ -1,7 +1,9 @@
1
1
  """Configuration for agent wallet backends."""
2
2
 
3
+ import hashlib
3
4
  import os
4
5
  from pathlib import Path
6
+ from typing import Iterator
5
7
 
6
8
  from pydantic_settings import BaseSettings
7
9
 
@@ -387,13 +389,16 @@ def _env_bool(name: str, default: bool) -> bool:
387
389
 
388
390
 
389
391
  def envelope_kdf_migration_enabled() -> bool:
390
- """Whether argon2id envelopes are lazily rewritten to hkdf-sha256 after a
391
- successful decrypt. Disable with AGENT_WALLET_ENVELOPE_KDF_MIGRATION=0
392
- (e.g. when a rollback to a pre-hkdf runtime must stay possible)."""
393
- return _env_bool("AGENT_WALLET_ENVELOPE_KDF_MIGRATION", True)
392
+ """Whether an explicit read may migrate argon2id envelopes with backups.
393
+
394
+ Disabled by default so normal reads never change storage or break rollback
395
+ to a pre-HKDF runtime. Opt in with AGENT_WALLET_ENVELOPE_KDF_MIGRATION=1.
396
+ """
397
+ return _env_bool("AGENT_WALLET_ENVELOPE_KDF_MIGRATION", False)
394
398
 
395
399
 
396
- _boot_key_keystore_cache: dict[str, str] = {}
400
+ _boot_key_keystore_cache: dict[tuple[str, str, str], str] = {}
401
+ _boot_key_validation_cache: dict[tuple[str, str, int, int], bool] = {}
397
402
 
398
403
 
399
404
  def clear_secret_caches() -> None:
@@ -405,6 +410,7 @@ def clear_secret_caches() -> None:
405
410
  directly in tests that rotate keystore contents in-process.
406
411
  """
407
412
  _boot_key_keystore_cache.clear()
413
+ _boot_key_validation_cache.clear()
408
414
  from agent_wallet.keystore import clear_keystore_cache
409
415
 
410
416
  clear_keystore_cache()
@@ -425,40 +431,136 @@ def read_boot_key_from_keystore() -> str:
425
431
  try:
426
432
  from agent_wallet.keystore import BOOT_KEY_ITEM, resolve_keystore
427
433
 
428
- service_key = os.getenv("AGENT_WALLET_KEYSTORE_SERVICE", "").strip()
429
- cached = _boot_key_keystore_cache.get(service_key)
434
+ cache_key = (
435
+ os.getenv("AGENT_WALLET_KEYSTORE_BACKEND", "auto").strip().lower(),
436
+ os.getenv("AGENT_WALLET_KEYSTORE_SERVICE", "").strip(),
437
+ str(resolve_openclaw_home().resolve()),
438
+ )
439
+ cached = _boot_key_keystore_cache.get(cache_key)
430
440
  if cached:
431
441
  return cached
432
442
  value = resolve_keystore().get(BOOT_KEY_ITEM)
433
443
  text = value.strip() if isinstance(value, str) else ""
434
444
  if text:
435
- _boot_key_keystore_cache[service_key] = text
445
+ _boot_key_keystore_cache[cache_key] = text
436
446
  return text
437
447
  except Exception:
438
448
  return ""
439
449
 
440
450
 
451
+ def _read_boot_key_file(path_value: str) -> str:
452
+ if not path_value.strip():
453
+ return ""
454
+ try:
455
+ return Path(path_value).expanduser().read_text(encoding="utf-8").strip()
456
+ except OSError:
457
+ return ""
458
+
459
+
460
+ def _boot_key_candidates() -> Iterator[tuple[str, str]]:
461
+ seen: set[str] = set()
462
+
463
+ def candidate(source: str, value: str) -> tuple[str, str] | None:
464
+ if not value or value in seen:
465
+ return None
466
+ seen.add(value)
467
+ return source, value
468
+
469
+ for item in (
470
+ candidate("environment", os.getenv("AGENT_WALLET_BOOT_KEY", "").strip()),
471
+ candidate("runtime_env", settings.agent_wallet_boot_key.strip()),
472
+ ):
473
+ if item:
474
+ yield item
475
+
476
+ keystore_item = candidate("keystore", read_boot_key_from_keystore())
477
+ if keystore_item:
478
+ yield keystore_item
479
+
480
+ configured_file = os.getenv(
481
+ "AGENT_WALLET_BOOT_KEY_FILE", settings.agent_wallet_boot_key_file
482
+ ).strip()
483
+ configured_item = candidate("configured_file", _read_boot_key_file(configured_file))
484
+ if configured_item:
485
+ yield configured_item
486
+
487
+ default_file = resolve_openclaw_home() / "agent-wallet-runtime" / "boot-key"
488
+ if not configured_file or Path(configured_file).expanduser() != default_file:
489
+ default_item = candidate("default_file", _read_boot_key_file(str(default_file)))
490
+ if default_item:
491
+ yield default_item
492
+
493
+
494
+ def _boot_key_unlocks_sealed_file(boot_key: str) -> bool | None:
495
+ sealed_path = resolve_openclaw_home() / "sealed_keys.json"
496
+ try:
497
+ stat = sealed_path.stat()
498
+ except FileNotFoundError:
499
+ return None
500
+ except OSError:
501
+ return False
502
+
503
+ cache_key = (boot_key, str(sealed_path), stat.st_mtime_ns, stat.st_size)
504
+ cached = _boot_key_validation_cache.get(cache_key)
505
+ if cached is not None:
506
+ return cached
507
+ try:
508
+ from agent_wallet.sealed_keys import unseal_keys
509
+
510
+ unseal_keys(boot_key)
511
+ valid = True
512
+ except Exception:
513
+ valid = False
514
+ if len(_boot_key_validation_cache) >= 8:
515
+ _boot_key_validation_cache.clear()
516
+ _boot_key_validation_cache[cache_key] = valid
517
+ return valid
518
+
519
+
520
+ def _resolve_boot_key_candidate() -> tuple[str, str, bool, list[str], bool]:
521
+ rejected: list[str] = []
522
+ for source, value in _boot_key_candidates():
523
+ verified = _boot_key_unlocks_sealed_file(value)
524
+ if verified is not False:
525
+ return value, source, verified is True, rejected, bool(rejected)
526
+ rejected.append(source)
527
+ return "", "none", False, rejected, len(rejected) > 1
528
+
529
+
441
530
  def resolve_boot_key() -> str:
442
- """Resolve the boot key used to unlock sealed secrets from disk.
531
+ """Resolve a boot key, verifying candidates against sealed secrets when present.
443
532
 
444
- Precedence (superset of the legacy lookup, so existing installs keep working):
533
+ Precedence remains compatible with legacy installs:
445
534
  1. AGENT_WALLET_BOOT_KEY env / settings override
446
535
  2. OS keystore (the hardened path)
447
536
  3. AGENT_WALLET_BOOT_KEY_FILE / boot-key file (legacy)
537
+
538
+ A stale higher-priority candidate cannot mask a lower-priority key that
539
+ actually unlocks ``sealed_keys.json``.
448
540
  """
449
- direct = os.getenv("AGENT_WALLET_BOOT_KEY", settings.agent_wallet_boot_key).strip()
450
- if direct:
451
- return direct
452
- from_keystore = read_boot_key_from_keystore()
453
- if from_keystore:
454
- return from_keystore
455
- key_file = os.getenv("AGENT_WALLET_BOOT_KEY_FILE", settings.agent_wallet_boot_key_file).strip()
456
- if not key_file:
457
- return ""
458
- try:
459
- return Path(key_file).expanduser().read_text(encoding="utf-8").strip()
460
- except OSError:
461
- return ""
541
+ return _resolve_boot_key_candidate()[0]
542
+
543
+
544
+ def resolve_boot_key_for_installer() -> str:
545
+ """Stable installer bridge; older runtimes lack this symbol and use JS fallback."""
546
+ return resolve_boot_key()
547
+
548
+
549
+ def boot_key_resolution_status() -> dict[str, object]:
550
+ """Return non-secret diagnostics for doctor/install reporting."""
551
+ from agent_wallet.keystore import keystore_backend_status
552
+
553
+ value, source, verified, rejected, conflict = _resolve_boot_key_candidate()
554
+ fingerprint = hashlib.sha256(value.encode("utf-8")).hexdigest()[:12] if value else None
555
+ return {
556
+ "available": bool(value),
557
+ "source": source,
558
+ "sealed_keys_verified": verified,
559
+ "conflict_detected": conflict,
560
+ "rejected_sources": rejected,
561
+ "fingerprint": fingerprint,
562
+ "keystore": keystore_backend_status(),
563
+ }
462
564
 
463
565
 
464
566
  def _reject_legacy_runtime_secret_env(var_name: str) -> None:
@@ -20,6 +20,7 @@ USER_SCOPED_KEY_SALT = b"openclaw-agent-wallet-user-key-v1"
20
20
 
21
21
  KDF_ARGON2ID = "argon2id"
22
22
  KDF_HKDF_SHA256 = "hkdf-sha256"
23
+ ARGON2ID_BACKUP_SUFFIX = ".argon2id.bak"
23
24
  _HKDF_INFO = b"openclaw-agent-wallet-envelope-hkdf-v1"
24
25
 
25
26
 
@@ -201,6 +202,89 @@ def decrypt_secret_material(
201
202
  return plaintext.decode("utf-8")
202
203
 
203
204
 
205
+ def migrate_envelope_to_hkdf(
206
+ path: Path,
207
+ secret_material: str,
208
+ *,
209
+ master_key: str,
210
+ metadata: dict[str, Any] | None = None,
211
+ ) -> Path | None:
212
+ """Rewrite one verified argon2id envelope with a rollback-safe backup."""
213
+ raw_text = path.read_text(encoding="utf-8")
214
+ if envelope_kdf(raw_text) != KDF_ARGON2ID:
215
+ return None
216
+ if decrypt_secret_material(raw_text, master_key=master_key) != secret_material:
217
+ raise WalletBackendError("Argon2id envelope verification failed before migration.")
218
+
219
+ backup = path.with_name(path.name + ARGON2ID_BACKUP_SUFFIX)
220
+ if backup.exists():
221
+ backup_text = backup.read_text(encoding="utf-8")
222
+ if envelope_kdf(backup_text) != KDF_ARGON2ID:
223
+ raise WalletBackendError(f"Envelope migration backup is invalid: {backup}")
224
+ if decrypt_secret_material(backup_text, master_key=master_key) != secret_material:
225
+ raise WalletBackendError(f"Envelope migration backup does not match: {backup}")
226
+ else:
227
+ atomic_write_text(backup, raw_text, mode=0o600)
228
+ if backup.read_text(encoding="utf-8") != raw_text:
229
+ raise WalletBackendError(f"Envelope migration backup could not be verified: {backup}")
230
+
231
+ if metadata is None:
232
+ try:
233
+ parsed = json.loads(raw_text)
234
+ original_metadata = parsed.get("metadata") if isinstance(parsed, dict) else None
235
+ except ValueError:
236
+ original_metadata = None
237
+ metadata = original_metadata if isinstance(original_metadata, dict) else None
238
+
239
+ migrated = encrypt_secret_material(
240
+ secret_material,
241
+ master_key=master_key,
242
+ metadata=metadata,
243
+ kdf=KDF_HKDF_SHA256,
244
+ )
245
+ if decrypt_secret_material(migrated, master_key=master_key) != secret_material:
246
+ raise WalletBackendError("HKDF envelope verification failed before migration.")
247
+ if path.read_text(encoding="utf-8") != raw_text:
248
+ raise WalletBackendError(f"Envelope changed during migration: {path}")
249
+
250
+ try:
251
+ atomic_write_text(path, migrated, mode=0o600)
252
+ written = path.read_text(encoding="utf-8")
253
+ if decrypt_secret_material(written, master_key=master_key) != secret_material:
254
+ raise WalletBackendError("HKDF envelope verification failed after migration.")
255
+ except Exception:
256
+ atomic_write_text(path, raw_text, mode=0o600)
257
+ raise
258
+ return backup
259
+
260
+
261
+ def restore_argon2id_envelope(path: Path, *, master_key: str) -> bool:
262
+ """Restore a migration backup only when it contains the same plaintext."""
263
+ backup = path.with_name(path.name + ARGON2ID_BACKUP_SUFFIX)
264
+ if not backup.exists():
265
+ return False
266
+ current_text = path.read_text(encoding="utf-8")
267
+ backup_text = backup.read_text(encoding="utf-8")
268
+ current_secret = decrypt_secret_material(current_text, master_key=master_key)
269
+ backup_secret = decrypt_secret_material(backup_text, master_key=master_key)
270
+ if current_secret != backup_secret:
271
+ raise WalletBackendError(
272
+ f"Refusing to restore stale envelope backup with different contents: {backup}"
273
+ )
274
+ if envelope_kdf(backup_text) != KDF_ARGON2ID:
275
+ raise WalletBackendError(f"Envelope migration backup is invalid: {backup}")
276
+ atomic_write_text(path, backup_text, mode=0o600)
277
+ return True
278
+
279
+
280
+ def remove_envelope_migration_backup(path: Path) -> None:
281
+ """Discard a rollback backup after the envelope's logical content changes."""
282
+ try:
283
+ path.with_name(path.name + ARGON2ID_BACKUP_SUFFIX).unlink()
284
+ except FileNotFoundError:
285
+ pass
286
+
287
+
204
288
  def load_wallet_secret_material(
205
289
  path: Path,
206
290
  *,
@@ -227,3 +311,4 @@ def write_encrypted_wallet_file(
227
311
  metadata=metadata,
228
312
  )
229
313
  atomic_write_text(path, payload, mode=0o600)
314
+ remove_envelope_migration_backup(path)