@agentlayer.tech/wallet 0.1.58 → 0.1.64

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.58",
5
+ "version": "0.1.64",
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.58",
3
+ "version": "0.1.64",
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,36 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## v0.1.62 - 2026-07-04
6
+
7
+ - Made default boot-key storage prompt-free on macOS. The automatic keystore
8
+ selection no longer probes macOS Keychain because even a read/probe through
9
+ `/usr/bin/security` can open a GUI password dialog during install or session
10
+ startup. macOS Keychain remains available only through explicit opt-in with
11
+ `AGENT_WALLET_KEYSTORE_BACKEND=macos-keychain` (or `native`), while the
12
+ default path uses the local fallback without showing Keychain prompts.
13
+ - `agent-wallet/agent_wallet/keystore.py`
14
+ - `agent-wallet/tests/smoke_keystore.py`
15
+
16
+ ## v0.1.61 - 2026-07-04
17
+
18
+ - Hardened boot-key installation and migration around desktop keystores:
19
+ - The npm installer provisions the boot key through the runtime Python
20
+ keystore bridge instead of unconditionally persisting it in the release
21
+ `.env`, while keeping a legacy `.env` fallback when no non-interactive
22
+ keystore round-trip is available.
23
+ - The Node keystore bridge is bounded by a timeout so a hung Python/keychain
24
+ operation cannot stall install or reinstall.
25
+ - macOS keychain writes now run with non-interactive stdin, convert timeouts
26
+ into failed command results, and make the problematic
27
+ `set-generic-password-partition-list` ACL update strictly best-effort.
28
+ - Keystore resolution now verifies that a native backend can read the live
29
+ boot key or complete a write/read/delete probe before selecting it, falling
30
+ back cleanly when the OS tool exists but cannot authorize writes.
31
+ - `agent-wallet/agent_wallet/keystore.py`
32
+ - `bin/openclaw-agent-wallet.mjs`
33
+ - `agent-wallet/tests/smoke_keystore.py`
34
+
5
35
  ## v0.1.58 - 2026-07-01
6
36
 
7
37
  - Resolved SPL token symbol/name in `get_wallet_portfolio` via a batched,
package/README.md CHANGED
@@ -102,7 +102,7 @@ The CLI uses a versioned runtime layout:
102
102
  ~/.openclaw/agent-wallet-runtime/current # symlink → active release
103
103
  ```
104
104
 
105
- On first install, `--yes` generates local runtime secrets. The installer stores `master_key` and `approval_secret` in `~/.openclaw/sealed_keys.json`; only the boot key needed to unlock that sealed bundle is written to the runtime `.env`.
105
+ On first install, `--yes` generates local runtime secrets. The installer stores `master_key` and `approval_secret` in `~/.openclaw/sealed_keys.json`; the boot key needed to unlock that sealed bundle stays in the prompt-free local fallback by default. macOS Keychain is opt-in (`AGENT_WALLET_KEYSTORE_BACKEND=macos-keychain`) because probing it can open a system password dialog during install or startup.
106
106
 
107
107
  Useful CLI commands (require the global install above):
108
108
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.58
1
+ 0.1.64
@@ -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.58"
5
+ __version__ = "0.1.64"
6
6
 
7
7
  __all__ = [
8
8
  "config",
@@ -0,0 +1,158 @@
1
+ """Transparent, verified migration of the boot key into the OS keystore.
2
+
3
+ Runs at backend startup. Non-destructive: plaintext is swept ONLY after the
4
+ keystore read-back verifies. Idempotent and best-effort — a partial sweep
5
+ re-runs cleanly next start. See BOOT_KEY_KEYCHAIN_ARCHITECTURE.md.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+
13
+ from agent_wallet.config import (
14
+ read_boot_key_from_keystore,
15
+ resolve_openclaw_home,
16
+ settings,
17
+ )
18
+ from agent_wallet.file_ops import atomic_write_text
19
+ from agent_wallet.keystore import BOOT_KEY_ITEM, PlaintextFileStore, resolve_keystore
20
+
21
+ _ENV_LINE_PREFIX = "AGENT_WALLET_BOOT_KEY="
22
+
23
+
24
+ def _read_legacy_boot_key() -> str:
25
+ """The authoritative live key from legacy sources only (env override -> file)."""
26
+ direct = os.getenv("AGENT_WALLET_BOOT_KEY", settings.agent_wallet_boot_key).strip()
27
+ if direct:
28
+ return direct
29
+ key_file = os.getenv("AGENT_WALLET_BOOT_KEY_FILE", settings.agent_wallet_boot_key_file).strip()
30
+ if key_file:
31
+ try:
32
+ return Path(key_file).expanduser().read_text(encoding="utf-8").strip()
33
+ except OSError:
34
+ return ""
35
+ return ""
36
+
37
+
38
+ def _env_boot_key_value(line: str) -> str | None:
39
+ """Return the boot-key value on an AGENT_WALLET_BOOT_KEY= line, else None."""
40
+ stripped = line.strip()
41
+ if not stripped.startswith(_ENV_LINE_PREFIX):
42
+ return None
43
+ return stripped[len(_ENV_LINE_PREFIX):].strip().strip('"').strip("'")
44
+
45
+
46
+ def _strip_boot_key_line(env_path: Path, expected: str) -> bool:
47
+ """Remove only the AGENT_WALLET_BOOT_KEY line whose value == expected.
48
+
49
+ Preserve all other vars, and never strip a line carrying a *different* value
50
+ (that would signal a key mismatch a human should look at). Returns True if
51
+ the file changed.
52
+ """
53
+ try:
54
+ lines = env_path.read_text(encoding="utf-8").splitlines()
55
+ except OSError:
56
+ return False
57
+ kept = [ln for ln in lines if _env_boot_key_value(ln) != expected]
58
+ if len(kept) == len(lines):
59
+ return False
60
+ atomic_write_text(env_path, ("\n".join(kept) + "\n") if kept else "", mode=0o600)
61
+ return True
62
+
63
+
64
+ def _candidate_env_files(runtime: Path) -> list[Path]:
65
+ """Boot-key .env locations to sweep, deduped.
66
+
67
+ Deliberately bounded: a recursive ``**/.env`` walk descends into the hundreds
68
+ of ``node_modules`` trees under the release dirs and effectively hangs onboarding
69
+ (observed: 650+ node_modules, glob never returns). The boot key was only ever
70
+ written to ``releases/<v>/agent-wallet/.env``; ``current``/``previous`` are
71
+ symlinks into ``releases`` and are resolved to dedupe against their targets.
72
+ """
73
+ candidates = list(runtime.glob("releases/*/agent-wallet/.env"))
74
+ candidates.append(runtime / "current" / "agent-wallet" / ".env")
75
+ candidates.append(runtime / "previous" / "agent-wallet" / ".env")
76
+ seen: set[str] = set()
77
+ out: list[Path] = []
78
+ for path in candidates:
79
+ try:
80
+ key = str(path.resolve())
81
+ except OSError:
82
+ key = str(path)
83
+ if key in seen:
84
+ continue
85
+ seen.add(key)
86
+ if path.exists():
87
+ out.append(path)
88
+ return out
89
+
90
+
91
+ def _sweep_plaintext(home: Path, expected: str) -> tuple[int, bool]:
92
+ """Strip matching boot-key lines from every runtime .env; delete the boot-key file.
93
+
94
+ Idempotent and value-scoped: only plaintext equal to the authoritative
95
+ keystore key is removed, so re-running after a fresh installer write (which
96
+ re-emits the same key) cleans it up on the next start.
97
+ """
98
+ runtime = home / "agent-wallet-runtime"
99
+ swept = 0
100
+ for env_path in _candidate_env_files(runtime):
101
+ try:
102
+ if _strip_boot_key_line(env_path, expected):
103
+ swept += 1
104
+ except Exception:
105
+ continue # best-effort; re-runs next start
106
+ removed = False
107
+ boot_file = runtime / "boot-key"
108
+ try:
109
+ if boot_file.read_text(encoding="utf-8").strip() == expected:
110
+ boot_file.unlink()
111
+ removed = True
112
+ except FileNotFoundError:
113
+ pass
114
+ except OSError:
115
+ pass
116
+ return swept, removed
117
+
118
+
119
+ def migrate_boot_key_to_keystore() -> dict:
120
+ """Move a legacy plaintext boot key into the keystore, verify, then sweep plaintext.
121
+
122
+ Runs on every startup (guarded once per process). When the keystore already
123
+ holds the key, it still re-sweeps any plaintext an installer re-emitted, so a
124
+ per-release .env write never re-establishes a permanent leak.
125
+ """
126
+ store = resolve_keystore()
127
+ home = resolve_openclaw_home()
128
+
129
+ # Do not sweep into a plaintext fallback: that offers no at-rest improvement,
130
+ # and the user explicitly chose to stay on the current file in that case.
131
+ if isinstance(store, PlaintextFileStore):
132
+ return {"migrated": False, "backend": store.backend_id, "swept_env_files": 0,
133
+ "removed_boot_key_file": False, "reason": "no-os-keystore"}
134
+
135
+ authoritative = read_boot_key_from_keystore()
136
+ first_time = False
137
+ if not authoritative:
138
+ legacy_key = _read_legacy_boot_key()
139
+ if not legacy_key:
140
+ return {"migrated": False, "backend": store.backend_id, "swept_env_files": 0,
141
+ "removed_boot_key_file": False, "reason": "no-legacy-key"}
142
+ try:
143
+ store.set(BOOT_KEY_ITEM, legacy_key)
144
+ except Exception as exc:
145
+ return {"migrated": False, "backend": store.backend_id, "swept_env_files": 0,
146
+ "removed_boot_key_file": False, "reason": f"keystore-set-failed: {exc}"}
147
+ # Verify-before-delete.
148
+ if read_boot_key_from_keystore() != legacy_key:
149
+ return {"migrated": False, "backend": store.backend_id, "swept_env_files": 0,
150
+ "removed_boot_key_file": False, "reason": "verify-failed"}
151
+ authoritative = legacy_key
152
+ first_time = True
153
+
154
+ swept, removed = _sweep_plaintext(home, authoritative)
155
+ migrated = first_time or swept > 0 or removed
156
+ return {"migrated": migrated, "backend": store.backend_id, "swept_env_files": swept,
157
+ "removed_boot_key_file": removed,
158
+ "reason": "ok" if migrated else "already-clean"}
@@ -0,0 +1,46 @@
1
+ """User-facing boot-key export/import for paper backup and machine recovery.
2
+
3
+ The boot key is a retrievable string (baseline A), so recovery is simple: show
4
+ it for a paper backup, or write a recorded value back into the keystore on a new
5
+ machine. Full recovery kit = boot key + the already-encrypted sealed_keys.json +
6
+ wallet files. See BOOT_KEY_KEYCHAIN_ARCHITECTURE.md.
7
+ """
8
+
9
+ from __future__ import annotations
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
13
+ from agent_wallet.wallet_layer.base import WalletBackendError
14
+
15
+ _EXPORT_WARNING = (
16
+ "SENSITIVE: anyone with this boot key AND your ~/.openclaw encrypted files "
17
+ "controls every wallet (Solana/EVM/BTC). Write it down and store it offline."
18
+ )
19
+
20
+
21
+ def export_boot_key() -> str:
22
+ """Return the live boot key. Raises if none is resolvable."""
23
+ key = resolve_boot_key()
24
+ if not key:
25
+ raise WalletBackendError(
26
+ "No boot key is available to export. The wallet is not set up on this machine."
27
+ )
28
+ return key
29
+
30
+
31
+ def import_boot_key(value: str) -> dict:
32
+ """Write a recorded boot key into the OS keystore and verify the read-back."""
33
+ key = str(value or "").strip()
34
+ if not key:
35
+ raise WalletBackendError("A non-empty boot key is required for import.")
36
+ store = resolve_keystore()
37
+ store.set(BOOT_KEY_ITEM, key)
38
+ if read_boot_key_from_keystore() != key:
39
+ raise WalletBackendError(
40
+ f"Boot key import could not be verified from the {store.backend_id} keystore."
41
+ )
42
+ return {"imported": True, "backend": store.backend_id}
43
+
44
+
45
+ def export_warning() -> str:
46
+ return _EXPORT_WARNING
@@ -385,11 +385,31 @@ def _env_bool(name: str, default: bool) -> bool:
385
385
  return raw.strip().lower() in {"1", "true", "yes", "on"}
386
386
 
387
387
 
388
+ def read_boot_key_from_keystore() -> str:
389
+ """Read the boot key from the OS keystore. Never raises; '' on any failure."""
390
+ try:
391
+ from agent_wallet.keystore import BOOT_KEY_ITEM, resolve_keystore
392
+
393
+ value = resolve_keystore().get(BOOT_KEY_ITEM)
394
+ return value.strip() if isinstance(value, str) else ""
395
+ except Exception:
396
+ return ""
397
+
398
+
388
399
  def resolve_boot_key() -> str:
389
- """Resolve the boot key used to unlock sealed secrets from disk."""
400
+ """Resolve the boot key used to unlock sealed secrets from disk.
401
+
402
+ Precedence (superset of the legacy lookup, so existing installs keep working):
403
+ 1. AGENT_WALLET_BOOT_KEY env / settings override
404
+ 2. OS keystore (the hardened path)
405
+ 3. AGENT_WALLET_BOOT_KEY_FILE / boot-key file (legacy)
406
+ """
390
407
  direct = os.getenv("AGENT_WALLET_BOOT_KEY", settings.agent_wallet_boot_key).strip()
391
408
  if direct:
392
409
  return direct
410
+ from_keystore = read_boot_key_from_keystore()
411
+ if from_keystore:
412
+ return from_keystore
393
413
  key_file = os.getenv("AGENT_WALLET_BOOT_KEY_FILE", settings.agent_wallet_boot_key_file).strip()
394
414
  if not key_file:
395
415
  return ""
@@ -0,0 +1,303 @@
1
+ """Cross-platform, dependency-free secret storage for the wallet boot key.
2
+
3
+ Backends shell out to tools already present on each desktop OS:
4
+ - macOS: /usr/bin/security (generic password in the login keychain)
5
+ - Windows: powershell DPAPI (ConvertFrom/ConvertTo-SecureString), per-user+machine
6
+ - Linux: secret-tool (libsecret) iff a Secret Service is reachable
7
+ - fallback: a 0600 plaintext file (current behavior) — never chosen over a
8
+ working keystore.
9
+
10
+ The store holds ONE install-level secret (the boot key). See
11
+ BOOT_KEY_KEYCHAIN_ARCHITECTURE.md.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import platform
18
+ import shutil
19
+ import subprocess
20
+ from pathlib import Path
21
+ from typing import Protocol, runtime_checkable
22
+
23
+ from agent_wallet.config import resolve_openclaw_home
24
+ from agent_wallet.file_ops import atomic_write_text, chmod_if_exists
25
+
26
+ KEYSTORE_SERVICE = "ai.agentlayer.wallet"
27
+ BOOT_KEY_ITEM = "boot_key"
28
+ _PROBE_ITEM = "__probe__"
29
+ _KEYSTORE_BACKEND_ENV = "AGENT_WALLET_KEYSTORE_BACKEND"
30
+
31
+ _SECURITY_BIN = "/usr/bin/security"
32
+ _SUBPROCESS_TIMEOUT = 10.0
33
+
34
+
35
+ def _service() -> str:
36
+ """Keychain/Secret-Service service name. Overridable so tests never touch the
37
+ real shared slot (the OS keychain is global, not scoped to OPENCLAW_HOME)."""
38
+ return os.getenv("AGENT_WALLET_KEYSTORE_SERVICE", "").strip() or KEYSTORE_SERVICE
39
+
40
+
41
+ def _backend_preference() -> str:
42
+ """Selected keystore backend.
43
+
44
+ auto (default) prefers the native OS keystore (macOS Keychain / Windows DPAPI /
45
+ Linux Secret Service) and falls back to a 0600 plaintext file. macOS Keychain
46
+ is prompt-free because set() writes with `-A` (open ACL, no partition-list).
47
+ Override with AGENT_WALLET_KEYSTORE_BACKEND=plaintext|macos-keychain|native|...
48
+ """
49
+ return os.getenv(_KEYSTORE_BACKEND_ENV, "auto").strip().lower()
50
+
51
+
52
+ class KeyStoreError(Exception):
53
+ """Raised when a keystore backend operation fails unexpectedly."""
54
+
55
+
56
+ @runtime_checkable
57
+ class KeyStore(Protocol):
58
+ backend_id: str
59
+
60
+ def available(self) -> bool: ...
61
+ def get(self, name: str) -> str | None: ...
62
+ def set(self, name: str, value: str) -> None: ...
63
+ def delete(self, name: str) -> None: ...
64
+
65
+
66
+ def _timeout_output(value: str | bytes | None) -> str:
67
+ if value is None:
68
+ return ""
69
+ if isinstance(value, bytes):
70
+ return value.decode(errors="replace")
71
+ return value
72
+
73
+
74
+ def _run(
75
+ argv: list[str],
76
+ *,
77
+ input_text: str | None = None,
78
+ timeout: float = _SUBPROCESS_TIMEOUT,
79
+ ) -> subprocess.CompletedProcess[str]:
80
+ kwargs: dict[str, object] = {
81
+ "capture_output": True,
82
+ "text": True,
83
+ "timeout": timeout,
84
+ "check": False,
85
+ }
86
+ if input_text is None:
87
+ kwargs["stdin"] = subprocess.DEVNULL
88
+ else:
89
+ kwargs["input"] = input_text
90
+ try:
91
+ return subprocess.run(argv, **kwargs)
92
+ except subprocess.TimeoutExpired as exc:
93
+ return subprocess.CompletedProcess(
94
+ argv,
95
+ 124,
96
+ _timeout_output(exc.stdout),
97
+ _timeout_output(exc.stderr) or f"timed out after {timeout:g}s",
98
+ )
99
+
100
+
101
+ class MacKeychainStore:
102
+ backend_id = "macos-keychain"
103
+
104
+ def available(self) -> bool:
105
+ return platform.system() == "Darwin" and Path(_SECURITY_BIN).exists()
106
+
107
+ def get(self, name: str) -> str | None:
108
+ proc = _run([_SECURITY_BIN, "find-generic-password", "-s", _service(), "-a", name, "-w"])
109
+ if proc.returncode != 0:
110
+ return None # item not found (44) or other non-fatal lookup miss
111
+ value = proc.stdout.rstrip("\n")
112
+ return value or None
113
+
114
+ def set(self, name: str, value: str) -> None:
115
+ # Recreate the item fresh with an open ACL. This is the ONLY reliably
116
+ # prompt-free way to write a login-keychain item non-interactively:
117
+ # -A -> accessible to any application without a prompt, and it
118
+ # bypasses the Sierra+ partition-list mechanism entirely.
119
+ # delete+add -> never -U-update an existing item; updating one created
120
+ # with a different ACL (or setting its partition list)
121
+ # pops a GUI keychain-password dialog in the background.
122
+ # Trade-off: any process running as this user can read the key without a
123
+ # prompt — the same runtime exposure as a 0600 file. At-rest protection
124
+ # (backups, synced home dirs, a stolen disk) is fully preserved.
125
+ _run([_SECURITY_BIN, "delete-generic-password", "-s", _service(), "-a", name])
126
+ proc = _run([
127
+ _SECURITY_BIN, "add-generic-password",
128
+ "-s", _service(), "-a", name,
129
+ "-w", value, "-A",
130
+ ])
131
+ if proc.returncode != 0:
132
+ raise KeyStoreError(f"security add-generic-password failed: {proc.stderr.strip()}")
133
+
134
+ def delete(self, name: str) -> None:
135
+ _run([_SECURITY_BIN, "delete-generic-password", "-s", _service(), "-a", name])
136
+
137
+
138
+ class WindowsDpapiStore:
139
+ backend_id = "windows-dpapi"
140
+
141
+ def _blob_path(self, name: str) -> Path:
142
+ return resolve_openclaw_home() / "keystore" / f"{name}.dpapi"
143
+
144
+ def available(self) -> bool:
145
+ return platform.system() == "Windows" and shutil.which("powershell") is not None
146
+
147
+ def get(self, name: str) -> str | None:
148
+ path = self._blob_path(name)
149
+ if not path.exists():
150
+ return None
151
+ blob = path.read_text(encoding="utf-8").strip()
152
+ if not blob:
153
+ return None
154
+ script = (
155
+ "$ErrorActionPreference='Stop';"
156
+ "$b=[Console]::In.ReadToEnd().Trim();"
157
+ "$ss=ConvertTo-SecureString $b;"
158
+ "$p=[Runtime.InteropServices.Marshal]::PtrToStringUni("
159
+ "[Runtime.InteropServices.Marshal]::SecureStringToBSTR($ss));"
160
+ "[Console]::Out.Write($p)"
161
+ )
162
+ proc = _run(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], input_text=blob)
163
+ if proc.returncode != 0:
164
+ return None
165
+ return proc.stdout or None
166
+
167
+ def set(self, name: str, value: str) -> None:
168
+ script = (
169
+ "$ErrorActionPreference='Stop';"
170
+ "$v=[Console]::In.ReadToEnd();"
171
+ "$ss=ConvertTo-SecureString $v -AsPlainText -Force;"
172
+ "[Console]::Out.Write((ConvertFrom-SecureString $ss))"
173
+ )
174
+ proc = _run(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], input_text=value)
175
+ if proc.returncode != 0 or not proc.stdout.strip():
176
+ raise KeyStoreError(f"DPAPI ConvertFrom-SecureString failed: {proc.stderr.strip()}")
177
+ atomic_write_text(self._blob_path(name), proc.stdout.strip() + "\n", mode=0o600)
178
+
179
+ def delete(self, name: str) -> None:
180
+ path = self._blob_path(name)
181
+ try:
182
+ path.unlink()
183
+ except FileNotFoundError:
184
+ pass
185
+
186
+
187
+ class LinuxSecretServiceStore:
188
+ backend_id = "linux-secretservice"
189
+
190
+ def available(self) -> bool:
191
+ if platform.system() != "Linux" or shutil.which("secret-tool") is None:
192
+ return False
193
+ # A probe lookup succeeds (rc 0/1) only when a Secret Service answers;
194
+ # a missing/unreachable service errors out (rc >1) or times out.
195
+ try:
196
+ proc = _run(["secret-tool", "lookup", "service", _service(), "account", "__probe__"])
197
+ except subprocess.TimeoutExpired:
198
+ return False
199
+ return proc.returncode in (0, 1)
200
+
201
+ def get(self, name: str) -> str | None:
202
+ proc = _run(["secret-tool", "lookup", "service", _service(), "account", name])
203
+ if proc.returncode != 0:
204
+ return None
205
+ value = proc.stdout.rstrip("\n")
206
+ return value or None
207
+
208
+ def set(self, name: str, value: str) -> None:
209
+ proc = _run(
210
+ ["secret-tool", "store", "--label", f"{_service()} {name}",
211
+ "service", _service(), "account", name],
212
+ input_text=value,
213
+ )
214
+ if proc.returncode != 0:
215
+ raise KeyStoreError(f"secret-tool store failed: {proc.stderr.strip()}")
216
+
217
+ def delete(self, name: str) -> None:
218
+ _run(["secret-tool", "clear", "service", _service(), "account", name])
219
+
220
+
221
+ class PlaintextFileStore:
222
+ """Fallback: a 0600 file under OPENCLAW_HOME/keystore. Always available."""
223
+
224
+ backend_id = "plaintext-file"
225
+
226
+ def _path(self, name: str) -> Path:
227
+ return resolve_openclaw_home() / "keystore" / f"{name}.plaintext"
228
+
229
+ def available(self) -> bool:
230
+ return True
231
+
232
+ def get(self, name: str) -> str | None:
233
+ path = self._path(name)
234
+ if not path.exists():
235
+ return None
236
+ chmod_if_exists(path)
237
+ value = path.read_text(encoding="utf-8").strip()
238
+ return value or None
239
+
240
+ def set(self, name: str, value: str) -> None:
241
+ atomic_write_text(self._path(name), value.strip() + "\n", mode=0o600)
242
+
243
+ def delete(self, name: str) -> None:
244
+ try:
245
+ self._path(name).unlink()
246
+ except FileNotFoundError:
247
+ pass
248
+
249
+
250
+ def resolve_keystore() -> KeyStore:
251
+ """Return the first available OS-native backend, else the plaintext fallback."""
252
+ preference = _backend_preference()
253
+ if preference in {"plain", "plaintext", "plaintext-file", "file"}:
254
+ return PlaintextFileStore()
255
+
256
+ candidates: list[KeyStore]
257
+ if preference in {"macos", "macos-keychain", "keychain"}:
258
+ candidates = [MacKeychainStore()]
259
+ elif preference in {"windows", "windows-dpapi", "dpapi"}:
260
+ candidates = [WindowsDpapiStore()]
261
+ elif preference in {"linux", "linux-secretservice", "secretservice"}:
262
+ candidates = [LinuxSecretServiceStore()]
263
+ elif preference == "native":
264
+ candidates = [MacKeychainStore(), WindowsDpapiStore(), LinuxSecretServiceStore()]
265
+ else:
266
+ # Auto: native OS keystore first, plaintext fallback last. macOS Keychain
267
+ # is prompt-free now that set() uses `-A` (open ACL, no partition-list) and
268
+ # _backend_usable falls back gracefully if a session still can't authorize.
269
+ candidates = [MacKeychainStore(), WindowsDpapiStore(), LinuxSecretServiceStore()]
270
+
271
+ for candidate in candidates:
272
+ try:
273
+ if candidate.available() and _backend_usable(candidate):
274
+ return candidate
275
+ except Exception:
276
+ continue
277
+ return PlaintextFileStore()
278
+
279
+
280
+ def _backend_usable(candidate: KeyStore) -> bool:
281
+ """Return true when a native backend can read the live key or write safely.
282
+
283
+ A desktop keystore binary can exist while the session cannot authorize
284
+ non-interactive writes. Treat that as unavailable so install/migration uses
285
+ the bounded fallback instead of hanging or repeatedly failing.
286
+ """
287
+ try:
288
+ if candidate.get(BOOT_KEY_ITEM):
289
+ return True
290
+ except Exception:
291
+ pass
292
+
293
+ probe_value = "ok"
294
+ try:
295
+ candidate.set(_PROBE_ITEM, probe_value)
296
+ return candidate.get(_PROBE_ITEM) == probe_value
297
+ except Exception:
298
+ return False
299
+ finally:
300
+ try:
301
+ candidate.delete(_PROBE_ITEM)
302
+ except Exception:
303
+ pass
@@ -582,6 +582,15 @@ def main() -> int:
582
582
  read_worker_parser.add_argument("--user-id", required=True)
583
583
  read_worker_parser.add_argument("--config-json", default="{}")
584
584
 
585
+ subparsers.add_parser("boot-key-export")
586
+
587
+ boot_key_import_parser = subparsers.add_parser("boot-key-import")
588
+ boot_key_import_parser.add_argument(
589
+ "--key-stdin",
590
+ action="store_true",
591
+ help="Read the boot key to import from stdin.",
592
+ )
593
+
585
594
  args = parser.parse_args()
586
595
 
587
596
  try:
@@ -705,6 +714,17 @@ def main() -> int:
705
714
  payload = asyncio.run(_run_evm_wallet_lock(args.user_id, config))
706
715
  elif args.command == "read-worker":
707
716
  return asyncio.run(_serve_read_worker(args.user_id, config))
717
+ elif args.command == "boot-key-export":
718
+ from agent_wallet.boot_key_recovery import export_boot_key, export_warning
719
+
720
+ print(export_warning(), file=sys.stderr)
721
+ payload = {"boot_key": export_boot_key()}
722
+ elif args.command == "boot-key-import":
723
+ if not args.key_stdin:
724
+ raise WalletBackendError("boot-key-import requires --key-stdin.")
725
+ from agent_wallet.boot_key_recovery import import_boot_key
726
+
727
+ payload = import_boot_key(_read_stdin_secret("boot key"))
708
728
  else:
709
729
  payload = asyncio.run(
710
730
  _run_invoke(
@@ -6,6 +6,7 @@ from dataclasses import dataclass
6
6
  from typing import Any
7
7
 
8
8
  from agent_wallet.approval import issue_approval_token
9
+ from agent_wallet.boot_key_migration import migrate_boot_key_to_keystore
9
10
  from agent_wallet.btc_user_wallets import get_user_btc_wallet_binding
10
11
  from agent_wallet.config import normalize_btc_network, normalize_evm_network, settings
11
12
  from agent_wallet.evm_user_wallets import ensure_user_evm_wallet_ready
@@ -19,6 +20,24 @@ from agent_wallet.wallet_layer.base import AgentWalletBackend, WalletBackendErro
19
20
  from agent_wallet.wallet_layer.wdk_evm import WdkEvmLocalWalletBackend
20
21
  from agent_wallet.wallet_layer.wdk_btc import WdkBtcLocalWalletBackend
21
22
 
23
+ _boot_key_migration_done = False
24
+
25
+
26
+ def ensure_boot_key_migrated_once() -> None:
27
+ """Run the boot-key keystore migration at most once per process. Never raises.
28
+
29
+ Best-effort: a failure here must never block wallet onboarding — the legacy
30
+ plaintext sources still resolve via ``resolve_boot_key``.
31
+ """
32
+ global _boot_key_migration_done
33
+ if _boot_key_migration_done:
34
+ return
35
+ _boot_key_migration_done = True
36
+ try:
37
+ migrate_boot_key_to_keystore()
38
+ except Exception:
39
+ pass
40
+
22
41
 
23
42
  @dataclass(slots=True)
24
43
  class OpenClawWalletRuntimeContext:
@@ -98,6 +117,7 @@ def onboard_openclaw_user_wallet(
98
117
  wdk_evm_account_index: int | None = None,
99
118
  ) -> OpenClawWalletRuntimeContext:
100
119
  """Provision and assemble a runtime-ready wallet context for one OpenClaw user."""
120
+ ensure_boot_key_migrated_once()
101
121
  backend_name = str(backend or settings.agent_wallet_backend).strip().lower()
102
122
  effective_sign_only = True if read_only else (
103
123
  settings.agent_wallet_sign_only if sign_only is None else sign_only
@@ -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.64",
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.64"
8
8
  description = "Plugin-friendly wallet backend for OpenClaw agents"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -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.64",
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.64",
4
4
  "description": "Codex plugin bridge for the AgentLayer wallet runtime.",
5
5
  "author": {
6
6
  "name": "AgentLayer"
@@ -1,5 +1,5 @@
1
1
  name: agent-wallet
2
- version: 0.1.58
2
+ version: 0.1.64
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.64",
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.64",
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.64",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "Separate EVM wallet service built on Tether WDK.",