@agentlayer.tech/wallet 0.1.90 → 0.1.92
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.
- package/.openclaw/extensions/agent-wallet/openclaw.plugin.json +1 -1
- package/.openclaw/extensions/agent-wallet/package.json +1 -1
- package/CHANGELOG.md +35 -0
- package/README.md +86 -467
- package/VERSION +1 -1
- package/agent-wallet/README.md +26 -1
- package/agent-wallet/UPGRADE_COMPATIBILITY.md +2 -1
- package/agent-wallet/agent_wallet/__init__.py +1 -1
- package/agent-wallet/agent_wallet/boot_key_migration.py +10 -21
- package/agent-wallet/agent_wallet/config.py +20 -0
- package/agent-wallet/agent_wallet/evm_user_wallets.py +176 -25
- package/agent-wallet/agent_wallet/keystore.py +112 -17
- package/agent-wallet/openclaw.plugin.json +1 -1
- package/agent-wallet/pyproject.toml +1 -1
- package/agent-wallet/scripts/install_agent_wallet.py +197 -11
- package/agent-wallet/scripts/install_openclaw_local_config.py +4 -1
- package/agent-wallet/scripts/install_openclaw_sealed_keys.py +6 -2
- package/bin/lib/evm-daemon.mjs +375 -0
- package/bin/lib/host-detection.mjs +236 -0
- package/bin/lib/integrations.mjs +69 -5
- package/bin/openclaw-agent-wallet.mjs +405 -28
- package/claude-code/plugins/agent-wallet/.claude-plugin/plugin.json +1 -1
- package/codex/plugins/agent-wallet/.codex-plugin/plugin.json +1 -1
- package/hermes/plugins/agent_wallet/plugin.yaml +1 -1
- package/package.json +8 -3
- package/wdk-btc-wallet/package.json +1 -1
- package/wdk-evm-wallet/package.json +3 -2
- package/wdk-evm-wallet/src/server.js +24 -3
- package/wdk-evm-wallet/src/shutdown.js +63 -0
package/agent-wallet/README.md
CHANGED
|
@@ -138,7 +138,20 @@ If you want a simpler local setup flow, use the installer:
|
|
|
138
138
|
sh ../setup.sh
|
|
139
139
|
```
|
|
140
140
|
|
|
141
|
-
|
|
141
|
+
The public npm entrypoint is a universal host orchestrator:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
npx @agentlayer.tech/wallet install --yes
|
|
145
|
+
npx @agentlayer.tech/wallet install --yes --hosts codex,claude-code
|
|
146
|
+
npx @agentlayer.tech/wallet detect --json
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
It detects OpenClaw, Codex, Claude Code, and Hermes before installation. The
|
|
150
|
+
direct `setup.sh`/Python path retains its OpenClaw-oriented default for
|
|
151
|
+
backward compatibility; the npm CLI passes `--no-configure-openclaw` and lets
|
|
152
|
+
the selected OpenClaw host adapter own `openclaw.json`.
|
|
153
|
+
|
|
154
|
+
If you already use a local coding agent on the same machine, it can run this installer for you. You can simply ask the agent to install the wallet, and it can perform the file setup, Python setup, and selected host-bridge setup automatically.
|
|
142
155
|
|
|
143
156
|
By default it will:
|
|
144
157
|
|
|
@@ -562,6 +575,18 @@ That native plugin package is additive. Keep the existing runtime installer for
|
|
|
562
575
|
npx @agentlayer.tech/wallet install --yes
|
|
563
576
|
```
|
|
564
577
|
|
|
578
|
+
To bind a welcome invite from `https://www.agent-layer.tech/onboard` to the
|
|
579
|
+
local Base address during installation:
|
|
580
|
+
|
|
581
|
+
```bash
|
|
582
|
+
npx @agentlayer.tech/wallet install --yes --invite alw_...
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
The installer sends only the one-time invite and the public EVM address to the
|
|
586
|
+
AgentLayer onboarding API. It never sends a private key, seed, wallet password,
|
|
587
|
+
local user ID, or telemetry install ID. A network or EVM provisioning failure
|
|
588
|
+
does not consume the invite and does not roll back the wallet installation.
|
|
589
|
+
|
|
565
590
|
The ClawHub plugin package auto-checks `~/.openclaw/agent-wallet-runtime/current/agent-wallet` before it falls back to a local workspace checkout.
|
|
566
591
|
|
|
567
592
|
Public-safe helper scripts are available in `agent-wallet/scripts/`:
|
|
@@ -20,8 +20,9 @@ Raising the floor requires all of the following:
|
|
|
20
20
|
|
|
21
21
|
| Path | Purpose | Removal condition |
|
|
22
22
|
|---|---|---|
|
|
23
|
-
| JavaScript boot-key fallback | Updates runtimes that predate the verified Python installer resolver. | The supported floor includes `resolve_boot_key_for_installer
|
|
23
|
+
| JavaScript boot-key fallback | Updates runtimes that predate the verified Python installer resolver. | The supported floor no longer includes releases without `resolve_boot_key_for_installer`; until then, isolated CI upgrades the published `0.1.53` package to the packed candidate and verifies boot-key, sealed-bundle, wallet-file, and address identity. |
|
|
24
24
|
| Directory runtime-pointer migration | Converts pre-versioned `current/` directories into release entries. | The supported floor guarantees symlink-based runtime pointers. |
|
|
25
|
+
| OpenClaw legacy adoption | Recognizes an AgentLayer entry whose package or extension path belongs to the active runtime home. | The supported floor guarantees an `integrations.json` OpenClaw ownership entry. |
|
|
25
26
|
| Hermes legacy adoption | Recognizes an AgentLayer plugin installed before the ownership registry. | The supported floor guarantees an `integrations.json` Hermes ownership entry. |
|
|
26
27
|
| Codex legacy adoption | Recognizes a matching local marketplace registration and AgentLayer manifest. | The supported floor guarantees an `integrations.json` Codex ownership entry. |
|
|
27
28
|
| Claude Code legacy adoption | Recognizes the AgentLayer marketplace and plugin manifest before registry ownership. | The supported floor guarantees an `integrations.json` Claude Code ownership entry. |
|
|
@@ -12,8 +12,8 @@ from pathlib import Path
|
|
|
12
12
|
|
|
13
13
|
from agent_wallet.config import (
|
|
14
14
|
read_boot_key_from_keystore,
|
|
15
|
+
resolve_boot_key,
|
|
15
16
|
resolve_openclaw_home,
|
|
16
|
-
settings,
|
|
17
17
|
)
|
|
18
18
|
from agent_wallet.file_ops import atomic_write_text
|
|
19
19
|
from agent_wallet.keystore import (
|
|
@@ -26,20 +26,6 @@ from agent_wallet.keystore import (
|
|
|
26
26
|
_ENV_LINE_PREFIX = "AGENT_WALLET_BOOT_KEY="
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
def _read_legacy_boot_key() -> str:
|
|
30
|
-
"""The authoritative live key from legacy sources only (env override -> file)."""
|
|
31
|
-
direct = os.getenv("AGENT_WALLET_BOOT_KEY", settings.agent_wallet_boot_key).strip()
|
|
32
|
-
if direct:
|
|
33
|
-
return direct
|
|
34
|
-
key_file = os.getenv("AGENT_WALLET_BOOT_KEY_FILE", settings.agent_wallet_boot_key_file).strip()
|
|
35
|
-
if key_file:
|
|
36
|
-
try:
|
|
37
|
-
return Path(key_file).expanduser().read_text(encoding="utf-8").strip()
|
|
38
|
-
except OSError:
|
|
39
|
-
return ""
|
|
40
|
-
return ""
|
|
41
|
-
|
|
42
|
-
|
|
43
29
|
def _env_boot_key_value(line: str) -> str | None:
|
|
44
30
|
"""Return the boot-key value on an AGENT_WALLET_BOOT_KEY= line, else None."""
|
|
45
31
|
stripped = line.strip()
|
|
@@ -140,23 +126,26 @@ def migrate_boot_key_to_keystore() -> dict:
|
|
|
140
126
|
"removed_boot_key_file": False, "reason": "no-os-keystore"}
|
|
141
127
|
|
|
142
128
|
authoritative = read_boot_key_from_keystore()
|
|
129
|
+
resolved = resolve_boot_key()
|
|
143
130
|
first_time = False
|
|
144
|
-
if
|
|
145
|
-
|
|
146
|
-
if not legacy_key:
|
|
131
|
+
if authoritative != resolved:
|
|
132
|
+
if not resolved:
|
|
147
133
|
return {"migrated": False, "backend": store.backend_id, "swept_env_files": 0,
|
|
148
134
|
"removed_boot_key_file": False, "reason": "no-legacy-key"}
|
|
149
135
|
try:
|
|
150
|
-
store.set(BOOT_KEY_ITEM,
|
|
136
|
+
store.set(BOOT_KEY_ITEM, resolved)
|
|
151
137
|
except Exception as exc:
|
|
152
138
|
return {"migrated": False, "backend": store.backend_id, "swept_env_files": 0,
|
|
153
139
|
"removed_boot_key_file": False, "reason": f"keystore-set-failed: {exc}"}
|
|
154
140
|
# Verify-before-delete.
|
|
155
|
-
if store.get(BOOT_KEY_ITEM) !=
|
|
141
|
+
if store.get(BOOT_KEY_ITEM) != resolved:
|
|
156
142
|
return {"migrated": False, "backend": store.backend_id, "swept_env_files": 0,
|
|
157
143
|
"removed_boot_key_file": False, "reason": "verify-failed"}
|
|
158
|
-
authoritative =
|
|
144
|
+
authoritative = resolved
|
|
159
145
|
first_time = True
|
|
146
|
+
elif not authoritative:
|
|
147
|
+
return {"migrated": False, "backend": store.backend_id, "swept_env_files": 0,
|
|
148
|
+
"removed_boot_key_file": False, "reason": "no-legacy-key"}
|
|
160
149
|
|
|
161
150
|
record_keystore_backend(store)
|
|
162
151
|
swept, removed = _sweep_plaintext(home, authoritative)
|
|
@@ -448,6 +448,19 @@ def read_boot_key_from_keystore() -> str:
|
|
|
448
448
|
return ""
|
|
449
449
|
|
|
450
450
|
|
|
451
|
+
def read_boot_key_from_legacy_unscoped_keystore() -> str:
|
|
452
|
+
"""Read the pre-home-scoping native service for compatibility only."""
|
|
453
|
+
sealed_path = resolve_openclaw_home() / "sealed_keys.json"
|
|
454
|
+
if not sealed_path.exists():
|
|
455
|
+
return ""
|
|
456
|
+
try:
|
|
457
|
+
from agent_wallet.keystore import read_legacy_unscoped_boot_key
|
|
458
|
+
|
|
459
|
+
return read_legacy_unscoped_boot_key()
|
|
460
|
+
except Exception:
|
|
461
|
+
return ""
|
|
462
|
+
|
|
463
|
+
|
|
451
464
|
def _read_boot_key_file(path_value: str) -> str:
|
|
452
465
|
if not path_value.strip():
|
|
453
466
|
return ""
|
|
@@ -477,6 +490,13 @@ def _boot_key_candidates() -> Iterator[tuple[str, str]]:
|
|
|
477
490
|
if keystore_item:
|
|
478
491
|
yield keystore_item
|
|
479
492
|
|
|
493
|
+
legacy_keystore_item = candidate(
|
|
494
|
+
"legacy_unscoped_keystore",
|
|
495
|
+
read_boot_key_from_legacy_unscoped_keystore(),
|
|
496
|
+
)
|
|
497
|
+
if legacy_keystore_item:
|
|
498
|
+
yield legacy_keystore_item
|
|
499
|
+
|
|
480
500
|
configured_file = os.getenv(
|
|
481
501
|
"AGENT_WALLET_BOOT_KEY_FILE", settings.agent_wallet_boot_key_file
|
|
482
502
|
).strip()
|
|
@@ -200,6 +200,121 @@ def _listening_pids(port: int) -> list[int]:
|
|
|
200
200
|
return pids
|
|
201
201
|
|
|
202
202
|
|
|
203
|
+
def _daemon_takeover_disabled() -> bool:
|
|
204
|
+
return os.getenv("OPENCLAW_EVM_DISABLE_DAEMON_TAKEOVER", "").strip().lower() in {
|
|
205
|
+
"1",
|
|
206
|
+
"true",
|
|
207
|
+
"yes",
|
|
208
|
+
"on",
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _takeover_refusal(port: int, listeners: list[int], data_dir: str, reason: str) -> str:
|
|
213
|
+
pids = ", ".join(str(pid) for pid in listeners) if listeners else "unknown"
|
|
214
|
+
return (
|
|
215
|
+
f"Refusing to stop the wdk-evm-wallet on port {port}: {reason}. "
|
|
216
|
+
f"Listening PIDs: {pids}. Daemon dataDir: {data_dir or 'unknown'}. "
|
|
217
|
+
f"To clear it manually: lsof -nP -iTCP:{port} -sTCP:LISTEN, then kill <pid>."
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _process_cwd(pid: int) -> Path | None:
|
|
222
|
+
"""Return a process working directory via lsof, or None when unverified."""
|
|
223
|
+
lsof = shutil.which("lsof")
|
|
224
|
+
if not lsof:
|
|
225
|
+
return None
|
|
226
|
+
try:
|
|
227
|
+
completed = subprocess.run( # noqa: S603
|
|
228
|
+
[lsof, "-a", "-p", str(pid), "-d", "cwd", "-Fn"],
|
|
229
|
+
capture_output=True,
|
|
230
|
+
text=True,
|
|
231
|
+
timeout=5,
|
|
232
|
+
)
|
|
233
|
+
except (OSError, subprocess.SubprocessError):
|
|
234
|
+
return None
|
|
235
|
+
if completed.returncode != 0:
|
|
236
|
+
return None
|
|
237
|
+
for line in completed.stdout.splitlines():
|
|
238
|
+
if line.startswith("n") and line[1:].strip():
|
|
239
|
+
try:
|
|
240
|
+
return Path(line[1:].strip()).resolve()
|
|
241
|
+
except OSError:
|
|
242
|
+
return None
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _process_exists(pid: int) -> bool:
|
|
247
|
+
try:
|
|
248
|
+
os.kill(pid, 0)
|
|
249
|
+
return True
|
|
250
|
+
except ProcessLookupError:
|
|
251
|
+
return False
|
|
252
|
+
except PermissionError:
|
|
253
|
+
return True
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _process_released_service(pid: int, port: int, service_url: str) -> bool:
|
|
257
|
+
if not _process_exists(pid):
|
|
258
|
+
return True
|
|
259
|
+
# A daemon signalled by a process other than its parent can remain briefly
|
|
260
|
+
# as a zombie. Once its listener and health endpoint are both gone, it no
|
|
261
|
+
# longer blocks the updated runtime and is considered released.
|
|
262
|
+
return pid not in _listening_pids(port) and _service_health(service_url) is None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _owner_matches(
|
|
266
|
+
owner: dict[str, Any] | None,
|
|
267
|
+
current_health: dict[str, Any],
|
|
268
|
+
*,
|
|
269
|
+
pid: int,
|
|
270
|
+
port: int,
|
|
271
|
+
) -> bool:
|
|
272
|
+
if owner is None:
|
|
273
|
+
return not _service_owner_path().exists()
|
|
274
|
+
try:
|
|
275
|
+
owner_pid = int(owner.get("pid") or 0)
|
|
276
|
+
owner_port = int(owner.get("port") or 0)
|
|
277
|
+
except (TypeError, ValueError):
|
|
278
|
+
return False
|
|
279
|
+
return (
|
|
280
|
+
owner_pid == pid
|
|
281
|
+
and owner_port == port
|
|
282
|
+
and _same_path(owner.get("data_dir"), _expected_local_service_data_dir())
|
|
283
|
+
and str(owner.get("instance_id") or "")
|
|
284
|
+
== str(current_health.get("instanceId") or "")
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _resolve_stoppable_pid(current_health: dict[str, Any], port: int) -> int:
|
|
289
|
+
"""Return a strictly verified same-home daemon PID, or 0 to fail closed."""
|
|
290
|
+
reported_data_dir = str(current_health.get("dataDir") or "").strip()
|
|
291
|
+
if not _same_path(reported_data_dir, _expected_local_service_data_dir()):
|
|
292
|
+
return 0
|
|
293
|
+
listeners = _listening_pids(port)
|
|
294
|
+
if not listeners:
|
|
295
|
+
return 0
|
|
296
|
+
try:
|
|
297
|
+
reported_pid = int(current_health.get("pid") or 0)
|
|
298
|
+
except (TypeError, ValueError):
|
|
299
|
+
reported_pid = 0
|
|
300
|
+
if reported_pid > 0:
|
|
301
|
+
if reported_pid not in listeners:
|
|
302
|
+
return 0
|
|
303
|
+
candidate = reported_pid
|
|
304
|
+
elif len(listeners) == 1:
|
|
305
|
+
# One-time compatibility for a pre-PID daemon. The listener and its
|
|
306
|
+
# working directory still have to identify the bundled service.
|
|
307
|
+
candidate = listeners[0]
|
|
308
|
+
else:
|
|
309
|
+
return 0
|
|
310
|
+
cwd = _process_cwd(candidate)
|
|
311
|
+
if cwd is None or cwd.name != "wdk-evm-wallet":
|
|
312
|
+
return 0
|
|
313
|
+
if not _owner_matches(_read_service_owner(), current_health, pid=candidate, port=port):
|
|
314
|
+
return 0
|
|
315
|
+
return candidate
|
|
316
|
+
|
|
317
|
+
|
|
203
318
|
def _stop_local_service(service_url: str, health: dict[str, Any] | None = None) -> None:
|
|
204
319
|
"""Gracefully stop a local wdk-evm-wallet daemon so a fresh one can start.
|
|
205
320
|
|
|
@@ -212,37 +327,57 @@ def _stop_local_service(service_url: str, health: dict[str, Any] | None = None)
|
|
|
212
327
|
f"Refusing to stop an unidentified service on port {port}."
|
|
213
328
|
)
|
|
214
329
|
listeners = _listening_pids(port)
|
|
215
|
-
owner = _read_service_owner()
|
|
216
|
-
expected_instance = _expected_local_service_instance_id()
|
|
217
|
-
reported_instance = str(current_health.get("instanceId") or "").strip()
|
|
218
330
|
reported_data_dir = str(current_health.get("dataDir") or "").strip()
|
|
219
|
-
try:
|
|
220
|
-
reported_pid = int(current_health.get("pid") or 0)
|
|
221
|
-
except (TypeError, ValueError):
|
|
222
|
-
reported_pid = 0
|
|
223
331
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
and int(owner.get("port") or 0) == port
|
|
332
|
+
if _daemon_takeover_disabled():
|
|
333
|
+
raise WalletBackendError(
|
|
334
|
+
_takeover_refusal(
|
|
335
|
+
port,
|
|
336
|
+
listeners,
|
|
337
|
+
reported_data_dir,
|
|
338
|
+
"takeover is disabled by OPENCLAW_EVM_DISABLE_DAEMON_TAKEOVER",
|
|
232
339
|
)
|
|
233
|
-
or int(owner.get("pid") or 0) not in listeners
|
|
234
340
|
)
|
|
235
|
-
if owner_matches and (not listeners or reported_pid in listeners):
|
|
236
|
-
owned_pid = reported_pid
|
|
237
|
-
elif not reported_instance and reported_data_dir and len(listeners) == 1:
|
|
238
|
-
# One-time compatibility path for pre-instance-id daemons.
|
|
239
|
-
owned_pid = listeners[0]
|
|
240
341
|
|
|
342
|
+
if not _same_path(reported_data_dir, _expected_local_service_data_dir()):
|
|
343
|
+
raise WalletBackendError(
|
|
344
|
+
_takeover_refusal(
|
|
345
|
+
port,
|
|
346
|
+
listeners,
|
|
347
|
+
reported_data_dir,
|
|
348
|
+
"the daemon belongs to a different wallet home",
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
owned_pid = _resolve_stoppable_pid(current_health, port)
|
|
241
353
|
if owned_pid <= 0:
|
|
242
354
|
raise WalletBackendError(
|
|
243
|
-
|
|
244
|
-
|
|
355
|
+
_takeover_refusal(
|
|
356
|
+
port,
|
|
357
|
+
listeners,
|
|
358
|
+
reported_data_dir,
|
|
359
|
+
"the listening process could not be identified",
|
|
360
|
+
)
|
|
245
361
|
)
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
os.kill(owned_pid, 0)
|
|
365
|
+
except ProcessLookupError:
|
|
366
|
+
try:
|
|
367
|
+
_service_owner_path().unlink()
|
|
368
|
+
except FileNotFoundError:
|
|
369
|
+
pass
|
|
370
|
+
return
|
|
371
|
+
except PermissionError as exc:
|
|
372
|
+
raise WalletBackendError(
|
|
373
|
+
_takeover_refusal(
|
|
374
|
+
port,
|
|
375
|
+
listeners,
|
|
376
|
+
reported_data_dir,
|
|
377
|
+
f"pid {owned_pid} belongs to another user ({exc})",
|
|
378
|
+
)
|
|
379
|
+
) from exc
|
|
380
|
+
|
|
246
381
|
try:
|
|
247
382
|
os.kill(owned_pid, signal.SIGTERM)
|
|
248
383
|
except ProcessLookupError:
|
|
@@ -253,20 +388,36 @@ def _stop_local_service(service_url: str, health: dict[str, Any] | None = None)
|
|
|
253
388
|
) from exc
|
|
254
389
|
deadline = time.time() + 10.0
|
|
255
390
|
while time.time() < deadline:
|
|
256
|
-
if
|
|
391
|
+
if _process_released_service(owned_pid, port, service_url):
|
|
257
392
|
try:
|
|
258
393
|
_service_owner_path().unlink()
|
|
259
394
|
except FileNotFoundError:
|
|
260
395
|
pass
|
|
261
396
|
return
|
|
262
397
|
time.sleep(0.3)
|
|
398
|
+
# Re-run the socket/cwd/owner checks immediately before a hard stop. A PID
|
|
399
|
+
# that exited and was reused must never inherit permission from old health.
|
|
400
|
+
refreshed_health = _service_health(service_url)
|
|
401
|
+
if (
|
|
402
|
+
not refreshed_health
|
|
403
|
+
or refreshed_health.get("service") != "wdk-evm-wallet"
|
|
404
|
+
or _resolve_stoppable_pid(refreshed_health, port) != owned_pid
|
|
405
|
+
):
|
|
406
|
+
raise WalletBackendError(
|
|
407
|
+
_takeover_refusal(
|
|
408
|
+
port,
|
|
409
|
+
_listening_pids(port),
|
|
410
|
+
str((refreshed_health or {}).get("dataDir") or ""),
|
|
411
|
+
"process identity changed before the hard stop",
|
|
412
|
+
)
|
|
413
|
+
)
|
|
263
414
|
try:
|
|
264
415
|
os.kill(owned_pid, signal.SIGKILL)
|
|
265
416
|
except ProcessLookupError:
|
|
266
417
|
pass
|
|
267
418
|
deadline = time.time() + 5.0
|
|
268
419
|
while time.time() < deadline:
|
|
269
|
-
if
|
|
420
|
+
if _process_released_service(owned_pid, port, service_url):
|
|
270
421
|
try:
|
|
271
422
|
_service_owner_path().unlink()
|
|
272
423
|
except FileNotFoundError:
|
|
@@ -13,6 +13,7 @@ BOOT_KEY_KEYCHAIN_ARCHITECTURE.md.
|
|
|
13
13
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
|
+
import hashlib
|
|
16
17
|
import json
|
|
17
18
|
import os
|
|
18
19
|
import platform
|
|
@@ -36,9 +37,53 @@ _SUBPROCESS_TIMEOUT = 10.0
|
|
|
36
37
|
|
|
37
38
|
|
|
38
39
|
def _service() -> str:
|
|
39
|
-
"""
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
"""Return the keystore namespace for the active wallet home.
|
|
41
|
+
|
|
42
|
+
Desktop keystores are global to the OS account, while ``OPENCLAW_HOME`` can
|
|
43
|
+
point at an isolated test or secondary installation. Keep the historical
|
|
44
|
+
service for the default home, but derive a stable namespace for every other
|
|
45
|
+
home so one installation cannot replace another installation's boot key.
|
|
46
|
+
Tests can still choose a readable throwaway namespace explicitly.
|
|
47
|
+
"""
|
|
48
|
+
explicit = os.getenv("AGENT_WALLET_KEYSTORE_SERVICE", "").strip()
|
|
49
|
+
if explicit:
|
|
50
|
+
return explicit
|
|
51
|
+
home = resolve_openclaw_home().expanduser().resolve()
|
|
52
|
+
default_home = (_account_home() / ".openclaw").resolve()
|
|
53
|
+
if home == default_home:
|
|
54
|
+
return KEYSTORE_SERVICE
|
|
55
|
+
home_hash = hashlib.sha256(str(home).encode("utf-8")).hexdigest()[:16]
|
|
56
|
+
return f"{KEYSTORE_SERVICE}.home-{home_hash}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _account_home() -> Path:
|
|
60
|
+
"""Return the OS account home without trusting a test-overridden ``HOME``."""
|
|
61
|
+
if os.name == "nt":
|
|
62
|
+
user_profile = os.getenv("USERPROFILE", "").strip()
|
|
63
|
+
if user_profile:
|
|
64
|
+
return Path(user_profile)
|
|
65
|
+
drive = os.getenv("HOMEDRIVE", "").strip()
|
|
66
|
+
home_path = os.getenv("HOMEPATH", "").strip()
|
|
67
|
+
if drive and home_path:
|
|
68
|
+
return Path(f"{drive}{home_path}")
|
|
69
|
+
try:
|
|
70
|
+
import pwd
|
|
71
|
+
|
|
72
|
+
return Path(pwd.getpwuid(os.getuid()).pw_dir)
|
|
73
|
+
except (ImportError, KeyError, OSError):
|
|
74
|
+
return Path.home()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _legacy_unscoped_service() -> str | None:
|
|
78
|
+
"""Return the pre-home-scoping service for safe compatibility reads."""
|
|
79
|
+
if os.getenv("AGENT_WALLET_KEYSTORE_SERVICE", "").strip():
|
|
80
|
+
return None
|
|
81
|
+
if _service() == KEYSTORE_SERVICE:
|
|
82
|
+
return None
|
|
83
|
+
state = _read_backend_state_payload()
|
|
84
|
+
if state and state["service"] == KEYSTORE_SERVICE:
|
|
85
|
+
return KEYSTORE_SERVICE
|
|
86
|
+
return None
|
|
42
87
|
|
|
43
88
|
|
|
44
89
|
def _backend_preference() -> str:
|
|
@@ -56,7 +101,7 @@ def _state_path() -> Path:
|
|
|
56
101
|
return resolve_openclaw_home() / "keystore" / _KEYSTORE_STATE_FILENAME
|
|
57
102
|
|
|
58
103
|
|
|
59
|
-
def
|
|
104
|
+
def _read_backend_state_payload() -> dict[str, str] | None:
|
|
60
105
|
try:
|
|
61
106
|
payload = json.loads(_state_path().read_text(encoding="utf-8"))
|
|
62
107
|
except (OSError, ValueError):
|
|
@@ -72,9 +117,17 @@ def _read_backend_state() -> dict[str, str] | None:
|
|
|
72
117
|
"plaintext-file",
|
|
73
118
|
}:
|
|
74
119
|
return None
|
|
120
|
+
return {"backend": backend, "service": service}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _read_backend_state() -> dict[str, str] | None:
|
|
124
|
+
payload = _read_backend_state_payload()
|
|
125
|
+
if payload is None:
|
|
126
|
+
return None
|
|
127
|
+
service = payload["service"]
|
|
75
128
|
if service != _service():
|
|
76
129
|
return None
|
|
77
|
-
return
|
|
130
|
+
return payload
|
|
78
131
|
|
|
79
132
|
|
|
80
133
|
class KeyStoreError(Exception):
|
|
@@ -129,11 +182,14 @@ def _run(
|
|
|
129
182
|
class MacKeychainStore:
|
|
130
183
|
backend_id = "macos-keychain"
|
|
131
184
|
|
|
185
|
+
def __init__(self, service: str | None = None) -> None:
|
|
186
|
+
self.service = service or _service()
|
|
187
|
+
|
|
132
188
|
def available(self) -> bool:
|
|
133
189
|
return platform.system() == "Darwin" and Path(_SECURITY_BIN).exists()
|
|
134
190
|
|
|
135
191
|
def get(self, name: str) -> str | None:
|
|
136
|
-
proc = _run([_SECURITY_BIN, "find-generic-password", "-s",
|
|
192
|
+
proc = _run([_SECURITY_BIN, "find-generic-password", "-s", self.service, "-a", name, "-w"])
|
|
137
193
|
if proc.returncode != 0:
|
|
138
194
|
return None # item not found (44) or other non-fatal lookup miss
|
|
139
195
|
value = proc.stdout.rstrip("\n")
|
|
@@ -150,17 +206,17 @@ class MacKeychainStore:
|
|
|
150
206
|
# Trade-off: any process running as this user can read the key without a
|
|
151
207
|
# prompt — the same runtime exposure as a 0600 file. At-rest protection
|
|
152
208
|
# (backups, synced home dirs, a stolen disk) is fully preserved.
|
|
153
|
-
_run([_SECURITY_BIN, "delete-generic-password", "-s",
|
|
209
|
+
_run([_SECURITY_BIN, "delete-generic-password", "-s", self.service, "-a", name])
|
|
154
210
|
proc = _run([
|
|
155
211
|
_SECURITY_BIN, "add-generic-password",
|
|
156
|
-
"-s",
|
|
212
|
+
"-s", self.service, "-a", name,
|
|
157
213
|
"-w", value, "-A",
|
|
158
214
|
])
|
|
159
215
|
if proc.returncode != 0:
|
|
160
216
|
raise KeyStoreError(f"security add-generic-password failed: {proc.stderr.strip()}")
|
|
161
217
|
|
|
162
218
|
def delete(self, name: str) -> None:
|
|
163
|
-
_run([_SECURITY_BIN, "delete-generic-password", "-s",
|
|
219
|
+
_run([_SECURITY_BIN, "delete-generic-password", "-s", self.service, "-a", name])
|
|
164
220
|
|
|
165
221
|
|
|
166
222
|
class WindowsDpapiStore:
|
|
@@ -215,19 +271,22 @@ class WindowsDpapiStore:
|
|
|
215
271
|
class LinuxSecretServiceStore:
|
|
216
272
|
backend_id = "linux-secretservice"
|
|
217
273
|
|
|
274
|
+
def __init__(self, service: str | None = None) -> None:
|
|
275
|
+
self.service = service or _service()
|
|
276
|
+
|
|
218
277
|
def available(self) -> bool:
|
|
219
278
|
if platform.system() != "Linux" or shutil.which("secret-tool") is None:
|
|
220
279
|
return False
|
|
221
280
|
# A probe lookup succeeds (rc 0/1) only when a Secret Service answers;
|
|
222
281
|
# a missing/unreachable service errors out (rc >1) or times out.
|
|
223
282
|
try:
|
|
224
|
-
proc = _run(["secret-tool", "lookup", "service",
|
|
283
|
+
proc = _run(["secret-tool", "lookup", "service", self.service, "account", "__probe__"])
|
|
225
284
|
except subprocess.TimeoutExpired:
|
|
226
285
|
return False
|
|
227
286
|
return proc.returncode in (0, 1)
|
|
228
287
|
|
|
229
288
|
def get(self, name: str) -> str | None:
|
|
230
|
-
proc = _run(["secret-tool", "lookup", "service",
|
|
289
|
+
proc = _run(["secret-tool", "lookup", "service", self.service, "account", name])
|
|
231
290
|
if proc.returncode != 0:
|
|
232
291
|
return None
|
|
233
292
|
value = proc.stdout.rstrip("\n")
|
|
@@ -235,15 +294,15 @@ class LinuxSecretServiceStore:
|
|
|
235
294
|
|
|
236
295
|
def set(self, name: str, value: str) -> None:
|
|
237
296
|
proc = _run(
|
|
238
|
-
["secret-tool", "store", "--label", f"{
|
|
239
|
-
"service",
|
|
297
|
+
["secret-tool", "store", "--label", f"{self.service} {name}",
|
|
298
|
+
"service", self.service, "account", name],
|
|
240
299
|
input_text=value,
|
|
241
300
|
)
|
|
242
301
|
if proc.returncode != 0:
|
|
243
302
|
raise KeyStoreError(f"secret-tool store failed: {proc.stderr.strip()}")
|
|
244
303
|
|
|
245
304
|
def delete(self, name: str) -> None:
|
|
246
|
-
_run(["secret-tool", "clear", "service",
|
|
305
|
+
_run(["secret-tool", "clear", "service", self.service, "account", name])
|
|
247
306
|
|
|
248
307
|
|
|
249
308
|
class PlaintextFileStore:
|
|
@@ -340,16 +399,52 @@ def _resolve_keystore_uncached() -> KeyStore:
|
|
|
340
399
|
return PlaintextFileStore()
|
|
341
400
|
|
|
342
401
|
|
|
343
|
-
def _store_for_backend(backend_id: str) -> KeyStore | None:
|
|
402
|
+
def _store_for_backend(backend_id: str, *, service: str | None = None) -> KeyStore | None:
|
|
344
403
|
stores: dict[str, KeyStore] = {
|
|
345
|
-
"macos-keychain": MacKeychainStore(),
|
|
404
|
+
"macos-keychain": MacKeychainStore(service),
|
|
346
405
|
"windows-dpapi": WindowsDpapiStore(),
|
|
347
|
-
"linux-secretservice": LinuxSecretServiceStore(),
|
|
406
|
+
"linux-secretservice": LinuxSecretServiceStore(service),
|
|
348
407
|
"plaintext-file": PlaintextFileStore(),
|
|
349
408
|
}
|
|
350
409
|
return stores.get(backend_id)
|
|
351
410
|
|
|
352
411
|
|
|
412
|
+
def read_legacy_unscoped_boot_key() -> str:
|
|
413
|
+
"""Read the historical global service without probing or writing it.
|
|
414
|
+
|
|
415
|
+
This is used only as a compatibility candidate for an existing sealed
|
|
416
|
+
custom-home installation. The caller must verify the value against that
|
|
417
|
+
home's ``sealed_keys.json`` before accepting or migrating it.
|
|
418
|
+
"""
|
|
419
|
+
legacy_service = _legacy_unscoped_service()
|
|
420
|
+
preference = _backend_preference()
|
|
421
|
+
if not legacy_service or preference in {"plain", "plaintext", "plaintext-file", "file"}:
|
|
422
|
+
return ""
|
|
423
|
+
|
|
424
|
+
if preference in {"macos", "macos-keychain", "keychain"}:
|
|
425
|
+
candidates: list[KeyStore] = [MacKeychainStore(legacy_service)]
|
|
426
|
+
elif preference in {"linux", "linux-secretservice", "secretservice"}:
|
|
427
|
+
candidates = [LinuxSecretServiceStore(legacy_service)]
|
|
428
|
+
elif preference in {"windows", "windows-dpapi", "dpapi"}:
|
|
429
|
+
candidates = []
|
|
430
|
+
else:
|
|
431
|
+
candidates = [
|
|
432
|
+
MacKeychainStore(legacy_service),
|
|
433
|
+
LinuxSecretServiceStore(legacy_service),
|
|
434
|
+
]
|
|
435
|
+
|
|
436
|
+
for candidate in candidates:
|
|
437
|
+
try:
|
|
438
|
+
if not candidate.available():
|
|
439
|
+
continue
|
|
440
|
+
value = candidate.get(BOOT_KEY_ITEM)
|
|
441
|
+
if isinstance(value, str) and value.strip():
|
|
442
|
+
return value.strip()
|
|
443
|
+
except Exception:
|
|
444
|
+
continue
|
|
445
|
+
return ""
|
|
446
|
+
|
|
447
|
+
|
|
353
448
|
def record_keystore_backend(store: KeyStore) -> dict[str, object]:
|
|
354
449
|
"""Persist a verified boot-key backend without replacing a temporary fallback."""
|
|
355
450
|
existing = _read_backend_state()
|
|
@@ -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.
|
|
5
|
+
"version": "0.1.92",
|
|
6
6
|
"skills": ["skills/wallet-operator"],
|
|
7
7
|
"configSchema": {
|
|
8
8
|
"type": "object",
|