@agentlayer.tech/wallet 0.1.73 → 0.1.75

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.
@@ -22,11 +22,14 @@ from agent_wallet.config import (
22
22
  resolve_openclaw_home,
23
23
  settings,
24
24
  )
25
+ from agent_wallet.file_ops import atomic_write_text
25
26
  from agent_wallet.providers.wdk_evm_local import WdkEvmLocalClient
26
27
  from agent_wallet.user_wallets import normalize_user_id
27
28
  from agent_wallet.wallet_layer.base import WalletBackendError
28
29
 
29
30
  LOCAL_WDK_EVM_HOSTS = {"127.0.0.1", "localhost", "::1"}
31
+ _SERVICE_OWNER_FILENAME = "service-owner.json"
32
+ _INSTANCE_ID_FILENAME = "instance-id"
30
33
 
31
34
 
32
35
  def _normalize_evm_network(value: str | None) -> str:
@@ -93,6 +96,52 @@ def _expected_local_service_data_dir() -> Path:
93
96
  return (resolve_openclaw_home() / "wdk-evm-wallet").resolve()
94
97
 
95
98
 
99
+ def _expected_local_service_instance_id() -> str:
100
+ path = _expected_local_service_data_dir() / _INSTANCE_ID_FILENAME
101
+ try:
102
+ existing = path.read_text(encoding="utf-8").strip()
103
+ except OSError:
104
+ existing = ""
105
+ if existing:
106
+ return existing
107
+ generated = secrets.token_hex(16)
108
+ atomic_write_text(path, generated + "\n", mode=0o600)
109
+ return generated
110
+
111
+
112
+ def _service_owner_path() -> Path:
113
+ return _expected_local_service_data_dir() / _SERVICE_OWNER_FILENAME
114
+
115
+
116
+ def _read_service_owner() -> dict[str, Any] | None:
117
+ try:
118
+ payload = json.loads(_service_owner_path().read_text(encoding="utf-8"))
119
+ except (OSError, ValueError):
120
+ return None
121
+ return payload if isinstance(payload, dict) else None
122
+
123
+
124
+ def _write_service_owner(health: dict[str, Any], service_url: str) -> int:
125
+ expected_instance = _expected_local_service_instance_id()
126
+ instance_id = str(health.get("instanceId") or "").strip()
127
+ try:
128
+ pid = int(health.get("pid") or 0)
129
+ except (TypeError, ValueError):
130
+ pid = 0
131
+ if instance_id != expected_instance or pid <= 0:
132
+ raise WalletBackendError("wdk-evm-wallet health did not confirm local process ownership.")
133
+ payload = {
134
+ "version": 1,
135
+ "pid": pid,
136
+ "instance_id": instance_id,
137
+ "port": urlparse(service_url).port or 8081,
138
+ "data_dir": str(_expected_local_service_data_dir()),
139
+ "service_version": str(health.get("version") or "").strip() or None,
140
+ }
141
+ atomic_write_text(_service_owner_path(), json.dumps(payload, indent=2) + "\n", mode=0o600)
142
+ return pid
143
+
144
+
96
145
  def _same_path(left: str | Path | None, right: str | Path | None) -> bool:
97
146
  if left is None or right is None:
98
147
  return False
@@ -120,6 +169,10 @@ def _should_restart_local_service(
120
169
  if reported_data_dir and not _same_path(reported_data_dir, _expected_local_service_data_dir()):
121
170
  return True
122
171
 
172
+ reported_instance = str(health.get("instanceId") or "").strip()
173
+ if reported_data_dir and reported_instance != _expected_local_service_instance_id():
174
+ return True
175
+
123
176
  return False
124
177
 
125
178
 
@@ -147,42 +200,77 @@ def _listening_pids(port: int) -> list[int]:
147
200
  return pids
148
201
 
149
202
 
150
- def _stop_local_service(service_url: str) -> None:
203
+ def _stop_local_service(service_url: str, health: dict[str, Any] | None = None) -> None:
151
204
  """Gracefully stop a local wdk-evm-wallet daemon so a fresh one can start.
152
205
 
153
206
  SIGTERM the listener(s), wait for /health to drop, then SIGKILL as a fallback.
154
207
  """
155
208
  port = urlparse(service_url).port or 8081
156
- pids = _listening_pids(port)
157
- if not pids:
158
- # Nothing identifiable to stop (e.g. lsof missing). Surface a clear,
159
- # actionable error rather than racing into an EADDRINUSE start failure.
209
+ current_health = health if health is not None else _service_health(service_url)
210
+ if not current_health or current_health.get("service") != "wdk-evm-wallet":
160
211
  raise WalletBackendError(
161
- f"A stale wdk-evm-wallet is running on port {port} but could not be "
162
- "identified to restart it. Stop it manually and retry."
212
+ f"Refusing to stop an unidentified service on port {port}."
163
213
  )
164
- for pid in pids:
165
- try:
166
- os.kill(pid, signal.SIGTERM)
167
- except ProcessLookupError:
168
- continue
169
- except PermissionError as exc:
170
- raise WalletBackendError(
171
- f"Cannot stop stale wdk-evm-wallet (pid {pid}): {exc}."
172
- ) from exc
214
+ 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
+ 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
+
224
+ owned_pid = 0
225
+ if reported_instance == expected_instance and reported_pid > 0:
226
+ owner_matches = (
227
+ not owner
228
+ or (
229
+ int(owner.get("pid") or 0) == reported_pid
230
+ and str(owner.get("instance_id") or "") == expected_instance
231
+ and int(owner.get("port") or 0) == port
232
+ )
233
+ or int(owner.get("pid") or 0) not in listeners
234
+ )
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
+
241
+ if owned_pid <= 0:
242
+ raise WalletBackendError(
243
+ f"A stale wdk-evm-wallet is running on port {port} but ownership could not be verified. "
244
+ "Stop it manually and retry."
245
+ )
246
+ try:
247
+ os.kill(owned_pid, signal.SIGTERM)
248
+ except ProcessLookupError:
249
+ pass
250
+ except PermissionError as exc:
251
+ raise WalletBackendError(
252
+ f"Cannot stop stale wdk-evm-wallet (pid {owned_pid}): {exc}."
253
+ ) from exc
173
254
  deadline = time.time() + 10.0
174
255
  while time.time() < deadline:
175
256
  if _service_health(service_url) is None:
257
+ try:
258
+ _service_owner_path().unlink()
259
+ except FileNotFoundError:
260
+ pass
176
261
  return
177
262
  time.sleep(0.3)
178
- for pid in _listening_pids(port):
179
- try:
180
- os.kill(pid, signal.SIGKILL)
181
- except ProcessLookupError:
182
- continue
263
+ try:
264
+ os.kill(owned_pid, signal.SIGKILL)
265
+ except ProcessLookupError:
266
+ pass
183
267
  deadline = time.time() + 5.0
184
268
  while time.time() < deadline:
185
269
  if _service_health(service_url) is None:
270
+ try:
271
+ _service_owner_path().unlink()
272
+ except FileNotFoundError:
273
+ pass
186
274
  return
187
275
  time.sleep(0.3)
188
276
  raise WalletBackendError(f"Failed to stop stale wdk-evm-wallet on port {port}.")
@@ -222,8 +310,10 @@ def _auto_start_local_service(service_url: str, network: str) -> None:
222
310
  if not _is_local_service_url(service_url):
223
311
  return
224
312
  if not _should_restart_local_service(health, wallet_root=wallet_root):
313
+ if str(health.get("instanceId") or "").strip():
314
+ _write_service_owner(health, service_url)
225
315
  return
226
- _stop_local_service(service_url)
316
+ _stop_local_service(service_url, health)
227
317
  if not _is_local_service_url(service_url):
228
318
  raise WalletBackendError(
229
319
  f"wdk-evm-wallet is unreachable at {_health_url(service_url)} and auto-start only supports localhost URLs."
@@ -237,6 +327,7 @@ def _auto_start_local_service(service_url: str, network: str) -> None:
237
327
  env["HOST"] = parsed.hostname or "127.0.0.1"
238
328
  env["PORT"] = str(parsed.port or 8081)
239
329
  env["WDK_EVM_NETWORK"] = _normalize_evm_network(network)
330
+ env["WDK_EVM_INSTANCE_ID"] = _expected_local_service_instance_id()
240
331
  process = subprocess.Popen( # noqa: S603
241
332
  ["sh", str(wallet_root / "run-local.sh")],
242
333
  cwd=str(wallet_root),
@@ -248,7 +339,9 @@ def _auto_start_local_service(service_url: str, network: str) -> None:
248
339
  )
249
340
  deadline = time.time() + 30.0
250
341
  while time.time() < deadline:
251
- if _service_is_healthy(service_url):
342
+ health = _service_health(service_url)
343
+ if health is not None:
344
+ _write_service_owner(health, service_url)
252
345
  return
253
346
  if process.poll() is not None:
254
347
  raise WalletBackendError("wdk-evm-wallet exited before becoming healthy.")
@@ -13,6 +13,7 @@ BOOT_KEY_KEYCHAIN_ARCHITECTURE.md.
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
+ import json
16
17
  import os
17
18
  import platform
18
19
  import shutil
@@ -27,6 +28,8 @@ KEYSTORE_SERVICE = "ai.agentlayer.wallet"
27
28
  BOOT_KEY_ITEM = "boot_key"
28
29
  _PROBE_ITEM = "__probe__"
29
30
  _KEYSTORE_BACKEND_ENV = "AGENT_WALLET_KEYSTORE_BACKEND"
31
+ _KEYSTORE_STATE_VERSION = 1
32
+ _KEYSTORE_STATE_FILENAME = "backend.json"
30
33
 
31
34
  _SECURITY_BIN = "/usr/bin/security"
32
35
  _SUBPROCESS_TIMEOUT = 10.0
@@ -49,6 +52,31 @@ def _backend_preference() -> str:
49
52
  return os.getenv(_KEYSTORE_BACKEND_ENV, "auto").strip().lower()
50
53
 
51
54
 
55
+ def _state_path() -> Path:
56
+ return resolve_openclaw_home() / "keystore" / _KEYSTORE_STATE_FILENAME
57
+
58
+
59
+ def _read_backend_state() -> dict[str, str] | None:
60
+ try:
61
+ payload = json.loads(_state_path().read_text(encoding="utf-8"))
62
+ except (OSError, ValueError):
63
+ return None
64
+ if not isinstance(payload, dict) or payload.get("version") != _KEYSTORE_STATE_VERSION:
65
+ return None
66
+ backend = str(payload.get("backend") or "").strip()
67
+ service = str(payload.get("service") or "").strip()
68
+ if backend not in {
69
+ "macos-keychain",
70
+ "windows-dpapi",
71
+ "linux-secretservice",
72
+ "plaintext-file",
73
+ }:
74
+ return None
75
+ if service != _service():
76
+ return None
77
+ return {"backend": backend, "service": service}
78
+
79
+
52
80
  class KeyStoreError(Exception):
53
81
  """Raised when a keystore backend operation fails unexpectedly."""
54
82
 
@@ -247,7 +275,7 @@ class PlaintextFileStore:
247
275
  pass
248
276
 
249
277
 
250
- _keystore_cache: dict[tuple[str, str], KeyStore] = {}
278
+ _keystore_cache: dict[tuple[str, str, str], KeyStore] = {}
251
279
 
252
280
 
253
281
  def clear_keystore_cache() -> None:
@@ -263,7 +291,11 @@ def resolve_keystore() -> KeyStore:
263
291
  (preference, service) for the process lifetime. Cleared by
264
292
  ``agent_wallet.config.clear_secret_caches`` / ``reload_settings``.
265
293
  """
266
- cache_key = (_backend_preference(), _service())
294
+ cache_key = (
295
+ _backend_preference(),
296
+ _service(),
297
+ str(resolve_openclaw_home().resolve()),
298
+ )
267
299
  cached = _keystore_cache.get(cache_key)
268
300
  if cached is not None:
269
301
  return cached
@@ -293,6 +325,12 @@ def _resolve_keystore_uncached() -> KeyStore:
293
325
  # _backend_usable falls back gracefully if a session still can't authorize.
294
326
  candidates = [MacKeychainStore(), WindowsDpapiStore(), LinuxSecretServiceStore()]
295
327
 
328
+ if preference == "auto":
329
+ state = _read_backend_state()
330
+ pinned = _store_for_backend(state["backend"]) if state else None
331
+ if pinned is not None:
332
+ candidates = [pinned, *[item for item in candidates if item.backend_id != pinned.backend_id]]
333
+
296
334
  for candidate in candidates:
297
335
  try:
298
336
  if candidate.available() and _backend_usable(candidate):
@@ -302,6 +340,48 @@ def _resolve_keystore_uncached() -> KeyStore:
302
340
  return PlaintextFileStore()
303
341
 
304
342
 
343
+ def _store_for_backend(backend_id: str) -> KeyStore | None:
344
+ stores: dict[str, KeyStore] = {
345
+ "macos-keychain": MacKeychainStore(),
346
+ "windows-dpapi": WindowsDpapiStore(),
347
+ "linux-secretservice": LinuxSecretServiceStore(),
348
+ "plaintext-file": PlaintextFileStore(),
349
+ }
350
+ return stores.get(backend_id)
351
+
352
+
353
+ def record_keystore_backend(store: KeyStore) -> dict[str, object]:
354
+ """Persist a verified boot-key backend without replacing a temporary fallback."""
355
+ existing = _read_backend_state()
356
+ preference = _backend_preference()
357
+ if preference == "auto" and existing and existing["backend"] != store.backend_id:
358
+ return {
359
+ "recorded": False,
360
+ "backend": existing["backend"],
361
+ "fallback_backend": store.backend_id,
362
+ }
363
+ payload = {
364
+ "version": _KEYSTORE_STATE_VERSION,
365
+ "backend": store.backend_id,
366
+ "service": _service(),
367
+ }
368
+ atomic_write_text(_state_path(), json.dumps(payload, indent=2) + "\n", mode=0o600)
369
+ return {"recorded": True, "backend": store.backend_id, "fallback_backend": None}
370
+
371
+
372
+ def keystore_backend_status() -> dict[str, object]:
373
+ """Return non-secret diagnostics for the selected and persisted backend."""
374
+ state = _read_backend_state()
375
+ selected = resolve_keystore().backend_id
376
+ pinned = state["backend"] if state else None
377
+ return {
378
+ "selected": selected,
379
+ "pinned": pinned,
380
+ "fallback_active": bool(pinned and selected != pinned),
381
+ "preference": _backend_preference(),
382
+ }
383
+
384
+
305
385
  def _backend_usable(candidate: KeyStore) -> bool:
306
386
  """Return true when a native backend can read the live key or write safely.
307
387
 
@@ -5,7 +5,11 @@ from __future__ import annotations
5
5
  import json
6
6
  from pathlib import Path
7
7
 
8
- from agent_wallet.encrypted_storage import decrypt_secret_material, encrypt_secret_material
8
+ from agent_wallet.encrypted_storage import (
9
+ decrypt_secret_material,
10
+ encrypt_secret_material,
11
+ remove_envelope_migration_backup,
12
+ )
9
13
  from agent_wallet.file_ops import atomic_write_text
10
14
  from agent_wallet.wallet_layer.base import WalletBackendError
11
15
 
@@ -45,6 +49,7 @@ def seal_keys(boot_key: str, secrets: dict[str, str]) -> Path:
45
49
  encrypted = encrypt_secret_material(payload, master_key=boot_key)
46
50
  path = resolve_sealed_keys_path()
47
51
  atomic_write_text(path, encrypted, mode=0o600)
52
+ remove_envelope_migration_backup(path)
48
53
  clear_unseal_cache()
49
54
  return path
50
55
 
@@ -79,28 +84,20 @@ def unseal_keys(boot_key: str) -> dict[str, str]:
79
84
  for key, value in payload.items():
80
85
  if isinstance(key, str) and isinstance(value, str):
81
86
  secrets[key] = value
82
- _maybe_migrate_envelope_kdf(boot_key, secrets, raw_text)
87
+ _maybe_migrate_envelope_kdf(boot_key, plaintext, raw_text)
83
88
  _unseal_cache.clear()
84
89
  _unseal_cache[cache_key] = dict(secrets)
85
90
  return secrets
86
91
 
87
92
 
88
- def _maybe_migrate_envelope_kdf(boot_key: str, secrets: dict[str, str], raw_text: str) -> None:
89
- """Lazily re-seal an argon2id file as hkdf-sha256 after a successful unseal.
93
+ def _maybe_migrate_envelope_kdf(boot_key: str, plaintext: str, raw_text: str) -> None:
94
+ """Opt-in re-seal of argon2id as HKDF with a verified rollback backup.
90
95
 
91
- Best-effort: any failure leaves the (still readable) argon2id file in
92
- place. The boot key is machine-generated high entropy, so hkdf loses no
93
- security while dropping ~1s of KDF per cold process. Rollback caveat:
94
- pre-hkdf runtimes cannot read the rewritten file — kill switch is
95
- AGENT_WALLET_ENVELOPE_KDF_MIGRATION=0.
96
+ Normal reads never migrate. Set AGENT_WALLET_ENVELOPE_KDF_MIGRATION=1 to
97
+ opt in; failures leave the readable original in place.
96
98
  """
97
99
  from agent_wallet.config import envelope_kdf_migration_enabled
98
- from agent_wallet.encrypted_storage import (
99
- KDF_ARGON2ID,
100
- KDF_HKDF_SHA256,
101
- _default_write_kdf,
102
- envelope_kdf,
103
- )
100
+ from agent_wallet.encrypted_storage import KDF_ARGON2ID, _default_write_kdf, envelope_kdf
104
101
 
105
102
  try:
106
103
  if not envelope_kdf_migration_enabled():
@@ -109,9 +106,13 @@ def _maybe_migrate_envelope_kdf(boot_key: str, secrets: dict[str, str], raw_text
109
106
  return # forced-argon2id installs must not rewrite in place forever
110
107
  if envelope_kdf(raw_text) != KDF_ARGON2ID:
111
108
  return
112
- payload = json.dumps(secrets, indent=2)
113
- encrypted = encrypt_secret_material(payload, master_key=boot_key, kdf=KDF_HKDF_SHA256)
114
- atomic_write_text(resolve_sealed_keys_path(), encrypted, mode=0o600)
109
+ from agent_wallet.encrypted_storage import migrate_envelope_to_hkdf
110
+
111
+ migrate_envelope_to_hkdf(
112
+ resolve_sealed_keys_path(),
113
+ plaintext,
114
+ master_key=boot_key,
115
+ )
115
116
  clear_unseal_cache()
116
117
  except Exception:
117
118
  pass
@@ -183,12 +183,11 @@ def _maybe_migrate_wallet_envelope_kdf(
183
183
  key_scope: str | None,
184
184
  address: str,
185
185
  ) -> None:
186
- """Lazily rewrite an argon2id wallet envelope as hkdf-sha256.
186
+ """Opt-in rewrite of argon2id as HKDF with a verified rollback backup.
187
187
 
188
188
  Re-encrypts with the SAME key scope that just decrypted the file, so this
189
- never performs a scope migration through the back door. Best-effort: any
190
- failure leaves the (still readable) argon2id file untouched. Kill switch:
191
- AGENT_WALLET_ENVELOPE_KDF_MIGRATION=0.
189
+ never performs a scope migration through the back door. Normal reads do not
190
+ mutate storage; opt in with AGENT_WALLET_ENVELOPE_KDF_MIGRATION=1.
192
191
  """
193
192
  from agent_wallet.config import envelope_kdf_migration_enabled
194
193
  from agent_wallet.encrypted_storage import (
@@ -214,7 +213,9 @@ def _maybe_migrate_wallet_envelope_kdf(
214
213
  master_key = resolve_wallet_master_key()
215
214
  if not master_key.strip():
216
215
  return
217
- write_encrypted_wallet_file(
216
+ from agent_wallet.encrypted_storage import migrate_envelope_to_hkdf
217
+
218
+ migrate_envelope_to_hkdf(
218
219
  path,
219
220
  secret_material,
220
221
  master_key=master_key,
@@ -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.73",
5
+ "version": "0.1.75",
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.73"
7
+ version = "0.1.75"
8
8
  description = "Plugin-friendly wallet backend for OpenClaw agents"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -142,9 +142,11 @@ def _auto_start_local_service(
142
142
  # steady state is a no-op. See agent_wallet.evm_user_wallets for the shared
143
143
  # health/version/stop helpers.
144
144
  from agent_wallet.evm_user_wallets import (
145
+ _expected_local_service_instance_id,
145
146
  _service_health,
146
147
  _should_restart_local_service,
147
148
  _stop_local_service,
149
+ _write_service_owner,
148
150
  )
149
151
 
150
152
  restarted = False
@@ -152,7 +154,7 @@ def _auto_start_local_service(
152
154
  if health is not None:
153
155
  if not _should_restart_local_service(health, wallet_root=wdk_wallet_root):
154
156
  return {"started": False, "already_healthy": True}
155
- _stop_local_service(service_url)
157
+ _stop_local_service(service_url, health)
156
158
  restarted = True
157
159
 
158
160
  if not _is_local_service_url(service_url):
@@ -175,6 +177,7 @@ def _auto_start_local_service(
175
177
  env["HOST"] = host
176
178
  env["PORT"] = str(port)
177
179
  env["WDK_EVM_NETWORK"] = network
180
+ env["WDK_EVM_INSTANCE_ID"] = _expected_local_service_instance_id()
178
181
 
179
182
  with log_path.open("a", encoding="utf-8") as log_file:
180
183
  process = subprocess.Popen( # noqa: S603
@@ -189,12 +192,14 @@ def _auto_start_local_service(
189
192
 
190
193
  deadline = time.time() + 30.0
191
194
  while time.time() < deadline:
192
- if _service_is_healthy(service_url):
195
+ health = _service_health(service_url)
196
+ if health is not None:
197
+ pid = _write_service_owner(health, service_url)
193
198
  return {
194
199
  "started": True,
195
200
  "already_healthy": False,
196
201
  "restarted": restarted,
197
- "pid": process.pid,
202
+ "pid": pid,
198
203
  "log_path": str(log_path),
199
204
  }
200
205
  if process.poll() is not None:
@@ -25,6 +25,7 @@ INCLUDED_RUNTIME_ROOT_FILES = [
25
25
  "setup.sh",
26
26
  ]
27
27
  INCLUDED_RUNTIME_TOP_LEVEL_DIRS = [
28
+ "claude-code",
28
29
  "codex",
29
30
  ".openclaw",
30
31
  "agent-wallet",
@@ -862,6 +863,7 @@ def main() -> None:
862
863
  source_wdk_btc_root = Path(args.wdk_btc_root).expanduser().resolve()
863
864
  source_wdk_evm_root = Path(args.wdk_evm_root).expanduser().resolve()
864
865
  runtime_root = Path(args.runtime_root).expanduser().resolve()
866
+ final_runtime_root = os.getenv("OPENCLAW_INSTALL_FINAL_ROOT", "").strip()
865
867
  config_path = Path(args.config_path).expanduser()
866
868
  env_path = Path(args.env_path).expanduser()
867
869
  env_example_path = Path(args.env_example_path).expanduser()
@@ -1039,7 +1041,8 @@ def main() -> None:
1039
1041
  "extension_path": str(extension_path),
1040
1042
  "wdk_btc_root": str(wdk_btc_root),
1041
1043
  "wdk_evm_root": str(wdk_evm_root),
1042
- "runtime_root": str(runtime_root),
1044
+ "runtime_root": final_runtime_root or str(runtime_root),
1045
+ "staging_root": str(runtime_root) if final_runtime_root else None,
1043
1046
  "install_from_runtime": bool(args.install_from_runtime),
1044
1047
  "python_bin": str(python_bin),
1045
1048
  "venv_created": venv_created,