@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.
@@ -7,13 +7,20 @@ import hashlib
7
7
  import json
8
8
  import os
9
9
  import platform
10
+ import re
10
11
  import shutil
11
12
  import subprocess
12
13
  import sys
13
14
  import tempfile
15
+ import urllib.error
16
+ import urllib.request
14
17
  import venv
15
18
  from pathlib import Path
16
19
 
20
+ WELCOME_INVITE_PATTERN = re.compile(r"^alw_[A-Za-z0-9_-]{43}$")
21
+ DEFAULT_ONBOARDING_BIND_URL = "https://www.agent-layer.tech/api/onboarding/bind-wallet"
22
+ ONBOARDING_HTTP_TIMEOUT_SECONDS = 5.0
23
+
17
24
  INCLUDED_RUNTIME_ROOT_FILES = [
18
25
  ".env.example",
19
26
  "AGENTS.md",
@@ -192,9 +199,16 @@ def build_parser() -> argparse.ArgumentParser:
192
199
  parser.add_argument("--network", default="mainnet")
193
200
  parser.add_argument("--rpc-url", default="")
194
201
  parser.add_argument("--rpc-urls", default="")
202
+ parser.add_argument("--invite", default="")
195
203
  parser.add_argument("--sign-only", action=argparse.BooleanOptionalAction, default=False)
196
204
  parser.add_argument("--sync-runtime", action=argparse.BooleanOptionalAction, default=True)
197
205
  parser.add_argument("--install-from-runtime", action=argparse.BooleanOptionalAction, default=False)
206
+ parser.add_argument(
207
+ "--configure-openclaw",
208
+ action=argparse.BooleanOptionalAction,
209
+ default=True,
210
+ help="Patch openclaw.json during install (disabled by the universal host orchestrator).",
211
+ )
198
212
  parser.add_argument("--skip-python-setup", action=argparse.BooleanOptionalAction, default=False)
199
213
  parser.add_argument("--skip-node-setup", action=argparse.BooleanOptionalAction, default=False)
200
214
  parser.add_argument("--dry-run", action=argparse.BooleanOptionalAction, default=False)
@@ -856,6 +870,149 @@ def _bootstrap_evm_wallet(
856
870
  }
857
871
 
858
872
 
873
+ def _onboarding_bind_url() -> str:
874
+ return (
875
+ os.getenv("AGENTLAYER_ONBOARDING_BIND_URL", "").strip()
876
+ or DEFAULT_ONBOARDING_BIND_URL
877
+ )
878
+
879
+
880
+ def _onboarding_error_code(payload: object, fallback: str) -> str:
881
+ if not isinstance(payload, dict):
882
+ return fallback
883
+ code = str(payload.get("error") or "").strip().lower()
884
+ allowed = {
885
+ "address_already_used",
886
+ "internal_error",
887
+ "invalid_base_address",
888
+ "invalid_invite",
889
+ "invite_already_bound",
890
+ "invite_expired",
891
+ "invite_revoked",
892
+ "request_too_large",
893
+ }
894
+ return code if code in allowed else fallback
895
+
896
+
897
+ def _decode_onboarding_response(raw: bytes) -> object:
898
+ try:
899
+ return json.loads(raw.decode("utf-8"))
900
+ except (UnicodeDecodeError, json.JSONDecodeError):
901
+ return {}
902
+
903
+
904
+ def _bind_welcome_invite(
905
+ invite: str,
906
+ address: str,
907
+ *,
908
+ api_url: str | None = None,
909
+ timeout_seconds: float = ONBOARDING_HTTP_TIMEOUT_SECONDS,
910
+ opener: object = urllib.request.urlopen,
911
+ attempts: int = 2,
912
+ ) -> dict[str, object]:
913
+ normalized_invite = invite.strip()
914
+ normalized_address = address.strip()
915
+ if not WELCOME_INVITE_PATTERN.fullmatch(normalized_invite):
916
+ return {"ok": False, "status": "invalid_invite", "retryable": False}
917
+ if not re.fullmatch(r"0x[0-9a-fA-F]{40}", normalized_address):
918
+ return {"ok": False, "status": "invalid_base_address", "retryable": False}
919
+
920
+ body = json.dumps({"address": normalized_address}).encode("utf-8")
921
+ request = urllib.request.Request(
922
+ api_url or _onboarding_bind_url(),
923
+ data=body,
924
+ method="POST",
925
+ headers={
926
+ "Authorization": f"Bearer {normalized_invite}",
927
+ "Content-Type": "application/json",
928
+ "Accept": "application/json",
929
+ "User-Agent": "AgentLayer-Wallet-Installer",
930
+ },
931
+ )
932
+ max_attempts = max(1, min(int(attempts), 3))
933
+ for attempt in range(max_attempts):
934
+ try:
935
+ with opener(request, timeout=timeout_seconds) as response: # type: ignore[operator]
936
+ status_code = int(getattr(response, "status", 200))
937
+ payload = _decode_onboarding_response(response.read(65_536))
938
+ if status_code < 200 or status_code >= 300 or not isinstance(payload, dict):
939
+ if status_code >= 500 and attempt + 1 < max_attempts:
940
+ continue
941
+ return {
942
+ "ok": False,
943
+ "status": _onboarding_error_code(payload, "service_error"),
944
+ "retryable": status_code >= 500,
945
+ }
946
+ binding_status = str(payload.get("status") or "")
947
+ response_address = str(payload.get("address") or "")
948
+ if (
949
+ payload.get("ok") is not True
950
+ or binding_status not in {"bound", "already_bound"}
951
+ or response_address.lower() != normalized_address.lower()
952
+ ):
953
+ return {"ok": False, "status": "invalid_response", "retryable": True}
954
+ return {
955
+ "ok": True,
956
+ "status": binding_status,
957
+ "network": "base",
958
+ "address": response_address,
959
+ }
960
+ except urllib.error.HTTPError as exc:
961
+ payload = _decode_onboarding_response(exc.read(65_536))
962
+ if exc.code >= 500 and attempt + 1 < max_attempts:
963
+ continue
964
+ return {
965
+ "ok": False,
966
+ "status": _onboarding_error_code(payload, "service_error"),
967
+ "retryable": exc.code >= 500,
968
+ }
969
+ except (urllib.error.URLError, TimeoutError, OSError):
970
+ if attempt + 1 < max_attempts:
971
+ continue
972
+ return {"ok": False, "status": "network_error", "retryable": True}
973
+ return {"ok": False, "status": "network_error", "retryable": True}
974
+
975
+
976
+ def _bind_invite_after_evm_onboard(
977
+ invite: str,
978
+ evm_onboard_result: dict[str, object] | None,
979
+ *,
980
+ api_url: str | None = None,
981
+ opener: object = urllib.request.urlopen,
982
+ ) -> dict[str, object] | None:
983
+ if not invite.strip():
984
+ return None
985
+ if not isinstance(evm_onboard_result, dict) or not evm_onboard_result.get("ok"):
986
+ return {"ok": False, "status": "pending_evm_wallet", "retryable": True}
987
+ address = str(evm_onboard_result.get("address") or "").strip()
988
+ if not address:
989
+ return {"ok": False, "status": "pending_evm_wallet", "retryable": True}
990
+ return _bind_welcome_invite(
991
+ invite,
992
+ address,
993
+ api_url=api_url,
994
+ opener=opener,
995
+ )
996
+
997
+
998
+ def _invite_binding_warning(binding_result: dict[str, object]) -> str:
999
+ """Describe a failed invite bind without claiming it can always be retried."""
1000
+ status = str(binding_result.get("status") or "unknown")
1001
+ if status == "invite_already_bound":
1002
+ return (
1003
+ "warning: the welcome invite is already bound to a different Base "
1004
+ "wallet and cannot be used with this wallet. Status: "
1005
+ + status
1006
+ )
1007
+ if binding_result.get("retryable") is True:
1008
+ return (
1009
+ "warning: the welcome invite was not bound; the invite remains "
1010
+ "available for a safe retry. Status: "
1011
+ + status
1012
+ )
1013
+ return "warning: the welcome invite was not bound and cannot be retried. Status: " + status
1014
+
1015
+
859
1016
  def main() -> None:
860
1017
  args = build_parser().parse_args()
861
1018
  source_package_root = Path(args.package_root).expanduser().resolve()
@@ -909,6 +1066,7 @@ def main() -> None:
909
1066
  wdk_evm_root = source_wdk_evm_root
910
1067
 
911
1068
  install_config_script = package_root / "scripts" / "install_openclaw_local_config.py"
1069
+ install_sealed_keys_script = package_root / "scripts" / "install_openclaw_sealed_keys.py"
912
1070
  if args.install_from_runtime:
913
1071
  default_source_env_path = source_package_root / ".env"
914
1072
  default_source_venv_path = source_package_root / ".venv"
@@ -923,7 +1081,7 @@ def main() -> None:
923
1081
  env_created = _ensure_env_file(env_path, env_example_path)
924
1082
  boot_key_file_env_updated = _ensure_runtime_boot_key_file_env(env_path)
925
1083
  flash_bridge_env = _ensure_flash_bridge_env(env_path, package_root)
926
- config_created = _ensure_openclaw_config(config_path)
1084
+ config_created = _ensure_openclaw_config(config_path) if args.configure_openclaw else False
927
1085
 
928
1086
  python_bin = Path(sys.executable)
929
1087
  venv_created = False
@@ -988,23 +1146,33 @@ def main() -> None:
988
1146
  pending_env = _pending_env_names() if backend_enabled else []
989
1147
  configured = False
990
1148
  configure_stdout = ""
1149
+ sealed_keys_result: dict[str, object] | None = None
991
1150
  solana_onboard_result: dict[str, object] | None = None
992
1151
  evm_onboard_result: dict[str, object] | None = None
1152
+ invite_binding_result: dict[str, object] | None = None
993
1153
  if backend_enabled and not pending_env and not args.dry_run:
994
- result = subprocess.run(
995
- _build_next_steps(
996
- python_bin,
997
- install_config_script,
998
- args,
999
- package_root=package_root,
1000
- extension_path=extension_path,
1001
- ),
1154
+ sealed_result = subprocess.run(
1155
+ [str(python_bin), str(install_sealed_keys_script)],
1002
1156
  capture_output=True,
1003
1157
  text=True,
1004
1158
  check=True,
1005
1159
  )
1006
- configured = True
1007
- configure_stdout = result.stdout
1160
+ sealed_keys_result = json.loads(sealed_result.stdout)
1161
+ if args.configure_openclaw:
1162
+ result = subprocess.run(
1163
+ _build_next_steps(
1164
+ python_bin,
1165
+ install_config_script,
1166
+ args,
1167
+ package_root=package_root,
1168
+ extension_path=extension_path,
1169
+ ),
1170
+ capture_output=True,
1171
+ text=True,
1172
+ check=True,
1173
+ )
1174
+ configured = True
1175
+ configure_stdout = result.stdout
1008
1176
  solana_onboard_result = _bootstrap_solana_wallet(
1009
1177
  python_bin,
1010
1178
  package_root,
@@ -1027,6 +1195,21 @@ def main() -> None:
1027
1195
  file=sys.stderr,
1028
1196
  )
1029
1197
 
1198
+ if args.invite.strip():
1199
+ if args.dry_run:
1200
+ invite_binding_result = {
1201
+ "ok": False,
1202
+ "status": "skipped_dry_run",
1203
+ "retryable": True,
1204
+ }
1205
+ else:
1206
+ invite_binding_result = _bind_invite_after_evm_onboard(
1207
+ args.invite,
1208
+ evm_onboard_result,
1209
+ )
1210
+ if invite_binding_result and not invite_binding_result.get("ok"):
1211
+ print(_invite_binding_warning(invite_binding_result), file=sys.stderr)
1212
+
1030
1213
  print(
1031
1214
  json.dumps(
1032
1215
  {
@@ -1037,6 +1220,7 @@ def main() -> None:
1037
1220
  "flash_bridge_env": flash_bridge_env,
1038
1221
  "config_path": str(config_path),
1039
1222
  "config_created": config_created,
1223
+ "configure_openclaw": bool(args.configure_openclaw),
1040
1224
  "package_root": str(package_root),
1041
1225
  "extension_path": str(extension_path),
1042
1226
  "wdk_btc_root": str(wdk_btc_root),
@@ -1051,8 +1235,10 @@ def main() -> None:
1051
1235
  "runtime_sync": runtime_sync,
1052
1236
  "configured": configured,
1053
1237
  "pending_env": pending_env,
1238
+ "sealed_keys": sealed_keys_result,
1054
1239
  "solana_wallet": solana_onboard_result,
1055
1240
  "evm_wallet": evm_onboard_result,
1241
+ "invite_binding": invite_binding_result,
1056
1242
  "next_configure_command": _build_next_steps(
1057
1243
  python_bin,
1058
1244
  install_config_script,
@@ -271,7 +271,10 @@ def _maybe_install_sealed_keys() -> str | None:
271
271
  updates["wdk_evm_wallet_password"] = secrets.token_urlsafe(24)
272
272
  if not updates:
273
273
  return None
274
- return str(seal_keys(boot_key, {**existing, **updates}))
274
+ merged = {**existing, **updates}
275
+ if merged == existing:
276
+ return str(sealed_path)
277
+ return str(seal_keys(boot_key, merged))
275
278
 
276
279
 
277
280
  def _require_hardened_runtime_secrets(backend: str) -> str | None:
@@ -94,15 +94,19 @@ def main() -> None:
94
94
  "and/or SOLANA_AGENT_PRIVATE_KEY in the environment."
95
95
  )
96
96
 
97
- path = seal_keys(boot_key, secrets)
97
+ changed = not sealed_path.exists() or secrets != existing
98
+ path = seal_keys(boot_key, secrets) if changed else sealed_path
98
99
  print(
99
100
  json.dumps(
100
101
  {
101
102
  "ok": True,
102
103
  "path": str(path),
103
104
  "stored_keys": sorted(secrets.keys()),
104
- "updated_keys": sorted(set(updates.keys()) | set(generated_keys)),
105
+ "updated_keys": (
106
+ sorted(set(updates.keys()) | set(generated_keys)) if changed else []
107
+ ),
105
108
  "replaced": bool(args.replace),
109
+ "changed": changed,
106
110
  },
107
111
  indent=2,
108
112
  )
@@ -0,0 +1,375 @@
1
+ // Best-effort stop of the wdk-evm-wallet daemon that belongs to the wallet
2
+ // home being updated. The installer never trusts /health alone: the reported
3
+ // PID must also own the local listening socket and run from a wdk-evm-wallet
4
+ // working directory. Every failure is advisory and leaves the process alone.
5
+ import fs from "node:fs";
6
+ import http from "node:http";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import { spawnSync } from "node:child_process";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const DEFAULT_SERVICE_URL = "http://127.0.0.1:8081";
13
+ const STOP_TIMEOUT_MS = 10000;
14
+ const KILL_TIMEOUT_MS = 5000;
15
+ const LOCAL_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
16
+
17
+ function healthUrl(serviceUrl) {
18
+ return `${String(serviceUrl).replace(/\/+$/, "")}/health`;
19
+ }
20
+
21
+ function expandHome(value, env = process.env) {
22
+ const raw = String(value || "").trim();
23
+ const home = String(env.HOME || os.homedir()).trim() || os.homedir();
24
+ if (raw === "~") return home;
25
+ if (raw.startsWith("~/")) return path.join(home, raw.slice(2));
26
+ return raw;
27
+ }
28
+
29
+ export function daemonTakeoverDisabled(env = process.env) {
30
+ return ["1", "true", "yes", "on"].includes(
31
+ String(env.OPENCLAW_EVM_DISABLE_DAEMON_TAKEOVER || "").trim().toLowerCase(),
32
+ );
33
+ }
34
+
35
+ export function isLoopbackServiceUrl(serviceUrl) {
36
+ try {
37
+ const parsed = new URL(serviceUrl);
38
+ return parsed.protocol === "http:" && LOCAL_HOSTS.has(parsed.hostname);
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ export function expectedDataDirFor(env = process.env) {
45
+ const configured = String(env.WDK_EVM_DATA_DIR || "").trim();
46
+ if (configured) return path.resolve(expandHome(configured, env));
47
+ const home = expandHome(env.OPENCLAW_HOME || path.join(env.HOME || os.homedir(), ".openclaw"), env);
48
+ return path.resolve(home, "wdk-evm-wallet");
49
+ }
50
+
51
+ function samePath(left, right) {
52
+ if (!left || !right) return false;
53
+ try {
54
+ return path.resolve(expandHome(left)) === path.resolve(expandHome(right));
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ function readJsonFile(pathname) {
61
+ try {
62
+ return { present: true, valid: true, value: JSON.parse(fs.readFileSync(pathname, "utf8")) };
63
+ } catch (error) {
64
+ if (error?.code === "ENOENT") return { present: false, valid: true, value: null };
65
+ return { present: true, valid: false, value: null };
66
+ }
67
+ }
68
+
69
+ function readServiceOwner(dataDir) {
70
+ return readJsonFile(path.join(dataDir, "service-owner.json"));
71
+ }
72
+
73
+ function runLsof(args, env = process.env) {
74
+ const result = spawnSync("lsof", args, {
75
+ encoding: "utf8",
76
+ timeout: 5000,
77
+ env,
78
+ });
79
+ if (result.error) return null;
80
+ // lsof uses status 1 when no rows match. That is a valid empty result.
81
+ if (![0, 1].includes(result.status)) return null;
82
+ return String(result.stdout || "");
83
+ }
84
+
85
+ function parsePids(raw) {
86
+ return [...new Set(
87
+ String(raw || "")
88
+ .split(/\s+/)
89
+ .filter((token) => /^\d+$/.test(token))
90
+ .map((token) => Number(token))
91
+ .filter((pid) => Number.isInteger(pid) && pid > 0),
92
+ )];
93
+ }
94
+
95
+ export function inspectDaemonProcess(pid, port, env = process.env) {
96
+ const listenerOutput = runLsof(
97
+ ["-nP", "-t", "-iTCP:" + String(port), "-sTCP:LISTEN"],
98
+ env,
99
+ );
100
+ const cwdOutput = runLsof(["-a", "-p", String(pid), "-d", "cwd", "-Fn"], env);
101
+ if (listenerOutput === null || cwdOutput === null) {
102
+ return { available: false, listenerPids: [], cwd: "" };
103
+ }
104
+ const cwd = cwdOutput
105
+ .split(/\r?\n/)
106
+ .find((line) => line.startsWith("n"))
107
+ ?.slice(1)
108
+ .trim() || "";
109
+ return {
110
+ available: true,
111
+ listenerPids: parsePids(listenerOutput),
112
+ cwd,
113
+ };
114
+ }
115
+
116
+ function cwdLooksLikeEvmDaemon(cwd) {
117
+ if (!cwd) return false;
118
+ try {
119
+ return path.basename(path.resolve(cwd)) === "wdk-evm-wallet";
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+
125
+ function ownerMatches({ ownerState, health, pid, port, expectedDataDir }) {
126
+ if (!ownerState.valid) return false;
127
+ if (!ownerState.present) return true;
128
+ const owner = ownerState.value;
129
+ if (!owner || typeof owner !== "object") return false;
130
+ return (
131
+ Number(owner.pid) === pid &&
132
+ Number(owner.port) === port &&
133
+ samePath(owner.data_dir, expectedDataDir) &&
134
+ String(owner.instance_id || "") === String(health.instanceId || "")
135
+ );
136
+ }
137
+
138
+ export function classifyDaemonHealth(
139
+ health,
140
+ {
141
+ expectedDataDir,
142
+ port,
143
+ inspection = { available: false, listenerPids: [], cwd: "" },
144
+ ownerState = { present: false, valid: true, value: null },
145
+ } = {},
146
+ ) {
147
+ if (!health || typeof health !== "object") {
148
+ return { stoppable: false, reason: "not_running", pid: 0 };
149
+ }
150
+ if (health.service !== "wdk-evm-wallet") {
151
+ return { stoppable: false, reason: "foreign_service", pid: 0 };
152
+ }
153
+ const reportedDataDir = String(health.dataDir || "").trim();
154
+ if (!reportedDataDir || !samePath(reportedDataDir, expectedDataDir)) {
155
+ return { stoppable: false, reason: "foreign_vault", pid: 0 };
156
+ }
157
+ const pid = typeof health.pid === "number" ? health.pid : Number.NaN;
158
+ if (!Number.isInteger(pid) || pid <= 0) {
159
+ return { stoppable: false, reason: "no_pid", pid: 0 };
160
+ }
161
+ if (!inspection.available) {
162
+ return { stoppable: false, reason: "process_inspection_unavailable", pid };
163
+ }
164
+ if (!inspection.listenerPids.includes(pid)) {
165
+ return { stoppable: false, reason: "pid_not_listener", pid };
166
+ }
167
+ if (!cwdLooksLikeEvmDaemon(inspection.cwd)) {
168
+ return { stoppable: false, reason: "foreign_process", pid };
169
+ }
170
+ if (!ownerMatches({ ownerState, health, pid, port, expectedDataDir })) {
171
+ return { stoppable: false, reason: "owner_mismatch", pid };
172
+ }
173
+ return { stoppable: true, reason: "stoppable", pid };
174
+ }
175
+
176
+ export function readDaemonHealth(serviceUrl, timeoutMs = 1500) {
177
+ return new Promise((resolve) => {
178
+ let settled = false;
179
+ const done = (value) => {
180
+ if (!settled) {
181
+ settled = true;
182
+ resolve(value);
183
+ }
184
+ };
185
+ const request = http.get(healthUrl(serviceUrl), { timeout: timeoutMs }, (response) => {
186
+ if (response.statusCode !== 200) {
187
+ response.resume();
188
+ done(null);
189
+ return;
190
+ }
191
+ let raw = "";
192
+ response.setEncoding("utf8");
193
+ response.on("data", (chunk) => {
194
+ raw += chunk;
195
+ });
196
+ response.on("end", () => {
197
+ try {
198
+ done(JSON.parse(raw));
199
+ } catch {
200
+ done(null);
201
+ }
202
+ });
203
+ });
204
+ request.on("timeout", () => {
205
+ request.destroy();
206
+ done(null);
207
+ });
208
+ request.on("error", () => done(null));
209
+ });
210
+ }
211
+
212
+ function processExists(pid) {
213
+ try {
214
+ process.kill(pid, 0);
215
+ return true;
216
+ } catch (error) {
217
+ return error?.code !== "ESRCH";
218
+ }
219
+ }
220
+
221
+ function processIsSignalable(pid) {
222
+ try {
223
+ process.kill(pid, 0);
224
+ return true;
225
+ } catch {
226
+ return false;
227
+ }
228
+ }
229
+
230
+ function processStillMatches(pid, port, expectedDataDir, ownerState, env) {
231
+ const inspection = inspectDaemonProcess(pid, port, env);
232
+ if (
233
+ !inspection.available ||
234
+ !inspection.listenerPids.includes(pid) ||
235
+ !cwdLooksLikeEvmDaemon(inspection.cwd)
236
+ ) {
237
+ return false;
238
+ }
239
+ if (!ownerState.valid || !ownerState.present) return false;
240
+ const owner = ownerState.value;
241
+ return (
242
+ Number(owner?.pid) === pid &&
243
+ Number(owner?.port) === port &&
244
+ samePath(owner?.data_dir, expectedDataDir)
245
+ );
246
+ }
247
+
248
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
249
+
250
+ async function waitForExit(pid, timeoutMs) {
251
+ const deadline = Date.now() + timeoutMs;
252
+ while (Date.now() < deadline) {
253
+ if (!processExists(pid)) return true;
254
+ await sleep(300);
255
+ }
256
+ return !processExists(pid);
257
+ }
258
+
259
+ export async function stopLocalEvmDaemon({ serviceUrl, env = process.env } = {}) {
260
+ if (daemonTakeoverDisabled(env)) {
261
+ return { attempted: false, stopped: false, reason: "takeover_disabled", pid: 0 };
262
+ }
263
+ const url =
264
+ String(env.WDK_EVM_SERVICE_URL || serviceUrl || DEFAULT_SERVICE_URL).trim() ||
265
+ DEFAULT_SERVICE_URL;
266
+ if (!isLoopbackServiceUrl(url)) {
267
+ return { attempted: false, stopped: false, reason: "non_local_service_url", pid: 0 };
268
+ }
269
+
270
+ const parsed = new URL(url);
271
+ const port = Number(parsed.port || 80);
272
+ const expectedDataDir = expectedDataDirFor(env);
273
+ const health = await readDaemonHealth(url);
274
+ const reportedPid = Number(health?.pid || 0);
275
+ const inspection =
276
+ Number.isInteger(reportedPid) && reportedPid > 0
277
+ ? inspectDaemonProcess(reportedPid, port, env)
278
+ : { available: false, listenerPids: [], cwd: "" };
279
+ const ownerState = readServiceOwner(expectedDataDir);
280
+ const verdict = classifyDaemonHealth(health, {
281
+ expectedDataDir,
282
+ port,
283
+ inspection,
284
+ ownerState,
285
+ });
286
+ if (!verdict.stoppable) {
287
+ return { attempted: false, stopped: false, reason: verdict.reason, pid: verdict.pid };
288
+ }
289
+ if (!processIsSignalable(verdict.pid)) {
290
+ return { attempted: false, stopped: false, reason: "pid_not_signalable", pid: verdict.pid };
291
+ }
292
+ // Close the remaining PID-reuse window as much as portable macOS/Linux APIs
293
+ // allow by rechecking the listener immediately before the signal.
294
+ const finalInspection = inspectDaemonProcess(verdict.pid, port, env);
295
+ if (
296
+ !finalInspection.available ||
297
+ !finalInspection.listenerPids.includes(verdict.pid) ||
298
+ !cwdLooksLikeEvmDaemon(finalInspection.cwd)
299
+ ) {
300
+ return { attempted: false, stopped: false, reason: "identity_changed", pid: verdict.pid };
301
+ }
302
+ try {
303
+ process.kill(verdict.pid, "SIGTERM");
304
+ } catch (error) {
305
+ return {
306
+ attempted: true,
307
+ stopped: false,
308
+ reason: `signal_failed:${error?.code || "unknown"}`,
309
+ pid: verdict.pid,
310
+ };
311
+ }
312
+ if (await waitForExit(verdict.pid, STOP_TIMEOUT_MS)) {
313
+ return { attempted: true, stopped: true, reason: "stopped", pid: verdict.pid };
314
+ }
315
+
316
+ // A hard stop is only allowed when the exact same owned process still owns
317
+ // the socket. Missing owner evidence or any identity change fails closed.
318
+ if (!processStillMatches(verdict.pid, port, expectedDataDir, ownerState, env)) {
319
+ return { attempted: true, stopped: false, reason: "still_running_unverified", pid: verdict.pid };
320
+ }
321
+ try {
322
+ process.kill(verdict.pid, "SIGKILL");
323
+ } catch (error) {
324
+ if (error?.code !== "ESRCH") {
325
+ return {
326
+ attempted: true,
327
+ stopped: false,
328
+ reason: `kill_failed:${error?.code || "unknown"}`,
329
+ pid: verdict.pid,
330
+ };
331
+ }
332
+ }
333
+ const stopped = await waitForExit(verdict.pid, KILL_TIMEOUT_MS);
334
+ return {
335
+ attempted: true,
336
+ stopped,
337
+ reason: stopped ? "killed" : "still_running",
338
+ pid: verdict.pid,
339
+ };
340
+ }
341
+
342
+ // The install and rollback paths are synchronous. Run the bounded async stop
343
+ // worker in a short-lived subprocess rather than making the CLI lifecycle async.
344
+ export function stopLocalEvmDaemonSync({ env = process.env } = {}) {
345
+ const fallback = { attempted: false, stopped: false, reason: "subprocess_failed", pid: 0 };
346
+ try {
347
+ const result = spawnSync(process.execPath, [fileURLToPath(import.meta.url), "--stop"], {
348
+ encoding: "utf8",
349
+ timeout: STOP_TIMEOUT_MS + KILL_TIMEOUT_MS + 5000,
350
+ env,
351
+ });
352
+ if (result.status !== 0 || !result.stdout) return fallback;
353
+ return JSON.parse(result.stdout.trim());
354
+ } catch {
355
+ return fallback;
356
+ }
357
+ }
358
+
359
+ if (
360
+ process.argv[1] &&
361
+ fileURLToPath(import.meta.url) === process.argv[1] &&
362
+ process.argv.includes("--stop")
363
+ ) {
364
+ stopLocalEvmDaemon()
365
+ .then((result) => {
366
+ process.stdout.write(JSON.stringify(result));
367
+ process.exit(0);
368
+ })
369
+ .catch(() => {
370
+ process.stdout.write(
371
+ JSON.stringify({ attempted: false, stopped: false, reason: "worker_failed", pid: 0 }),
372
+ );
373
+ process.exit(0);
374
+ });
375
+ }