agent-lock-cli 0.1.1__py3-none-any.whl

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.
agent_lock/discover.py ADDED
@@ -0,0 +1,542 @@
1
+ """Find the mihomo mixed port. The controller secret is never placed in an error or a log line."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import socket
7
+ import sys
8
+ import urllib.parse
9
+
10
+ from .errors import AgentLockError, UsageError
11
+ from .state import proxy_url, secret_label
12
+
13
+ TIMEOUT = 3
14
+ MAX_BODY = 64 * 1024
15
+ PIPE_PREFIX = "\\\\.\\pipe\\"
16
+ VERGE_DIR = "io.github.clash-verge-rev.clash-verge-rev"
17
+ _STRING_FLAGS = ("d", "f", "ext-ctl", "ext-ctl-unix", "ext-ctl-pipe", "secret")
18
+ _CONTROLLER_FLAGS = ("ext-ctl", "ext-ctl-unix", "ext-ctl-pipe")
19
+ _CONFIG_KEY = re.compile(r"^(external-controller(?:-unix|-pipe)?|secret)\s*:(.*)$")
20
+ _LOOPBACK_BIND = ("127.0.0.1", "::1", "localhost")
21
+
22
+
23
+ class DiscoverFailure(AgentLockError):
24
+ def __init__(self, message, secret=None, code=1):
25
+ if secret:
26
+ message = message.replace(secret, secret_label(secret))
27
+ super(DiscoverFailure, self).__init__(message, code)
28
+
29
+
30
+ def read_secret_stdin(readline):
31
+ """One line, trailing newline removed. Empty input is exit 2."""
32
+ line = readline()
33
+ if line is None:
34
+ raise UsageError("empty secret on stdin")
35
+ if line.endswith("\n"):
36
+ line = line[:-1]
37
+ if line.endswith("\r"):
38
+ line = line[:-1]
39
+ if line == "":
40
+ raise UsageError("empty secret on stdin")
41
+ if "\n" in line or "\r" in line:
42
+ raise UsageError("secret must be a single line")
43
+ return line
44
+
45
+
46
+ def effective_port(data):
47
+ """mixed-port if it is a positive int, else port. SOCKS-only is an error."""
48
+ if not isinstance(data, dict):
49
+ raise DiscoverFailure("controller JSON was not an object")
50
+ mixed = _positive_int(data.get("mixed-port"))
51
+ if mixed:
52
+ return mixed
53
+ http_port = _positive_int(data.get("port"))
54
+ if http_port:
55
+ return http_port
56
+ if _positive_int(data.get("socks-port")):
57
+ raise DiscoverFailure("only a SOCKS port is open; Claude Code does not support SOCKS")
58
+ raise DiscoverFailure("controller JSON has no mixed-port or port")
59
+
60
+
61
+ def _positive_int(value):
62
+ if isinstance(value, bool) or not isinstance(value, int):
63
+ return 0
64
+ if value > 0:
65
+ return value
66
+ return 0
67
+
68
+
69
+ def check_lan_and_bind(data):
70
+ if data.get("allow-lan") is True:
71
+ raise DiscoverFailure("allow-lan is true; turn it off before installing")
72
+ bind = data.get("bind-address")
73
+ if bind is not None and str(bind) not in _LOOPBACK_BIND:
74
+ raise DiscoverFailure("bind-address is not loopback ({})".format(bind))
75
+
76
+
77
+ def interpret_response(status, body, secret):
78
+ """Return parsed JSON or raise. The body is never copied into the error."""
79
+ if status == 401:
80
+ raise DiscoverFailure("needs a secret", secret=secret)
81
+ if status is None:
82
+ raise DiscoverFailure("controller unreachable", secret=secret)
83
+ if status != 200:
84
+ raise DiscoverFailure("controller returned HTTP {}".format(status), secret=secret)
85
+ try:
86
+ if isinstance(body, bytes):
87
+ text = body.decode("utf-8", "replace")
88
+ else:
89
+ text = body or ""
90
+ data = json.loads(text)
91
+ except ValueError:
92
+ raise DiscoverFailure("controller did not return JSON", secret=secret)
93
+ return data
94
+
95
+
96
+ def _is_fatal(text):
97
+ return text.startswith((
98
+ "allow-lan",
99
+ "bind-address",
100
+ "only a SOCKS",
101
+ "refusing the external-controller",
102
+ ))
103
+
104
+
105
+ def log_line(address, source, detail):
106
+ """A controller log line: address, source, and mixed-port. No secret."""
107
+ return "{} ({}) {}".format(address, source, detail)
108
+
109
+
110
+ def parse_flags(args):
111
+ """Four flag spellings: -name value, -name=value, --name value, --name=value."""
112
+ flags = {}
113
+ index = 0
114
+ while index < len(args):
115
+ token = args[index]
116
+ index += 1
117
+ if not token.startswith("-") or token in ("-", "--"):
118
+ continue
119
+ name = token[2:] if token.startswith("--") else token[1:]
120
+ if "=" in name:
121
+ name, value = name.split("=", 1)
122
+ if name in _STRING_FLAGS:
123
+ flags[name] = value
124
+ elif name in _STRING_FLAGS and index < len(args):
125
+ flags[name] = args[index]
126
+ index += 1
127
+ return flags
128
+
129
+
130
+ def _is_loopback(host):
131
+ if not host:
132
+ return False
133
+ if host.lower() == "localhost":
134
+ return True
135
+ try:
136
+ import ipaddress
137
+ return ipaddress.ip_address(host).is_loopback
138
+ except ValueError:
139
+ return False
140
+
141
+
142
+ def normalize_tcp(value):
143
+ """Return (display, host, port) or None when the address is not local."""
144
+ try:
145
+ parts = urllib.parse.urlsplit("//" + value)
146
+ port = parts.port
147
+ host = parts.hostname or ""
148
+ except ValueError:
149
+ return None
150
+ if port is None:
151
+ return None
152
+ if host in ("", "0.0.0.0"):
153
+ host = "127.0.0.1"
154
+ elif host == "::":
155
+ host = "::1"
156
+ if not _is_loopback(host):
157
+ return None
158
+ if ":" in host:
159
+ display = "[{}]:{}".format(host, port)
160
+ else:
161
+ display = "{}:{}".format(host, port)
162
+ return display, host, port
163
+
164
+
165
+ class Candidate(object):
166
+ def __init__(self, address, secret, source, kind):
167
+ self.address = address
168
+ self.secret = secret or ""
169
+ self.source = source
170
+ self.kind = kind # tcp, unix, pipe
171
+
172
+
173
+ def config_owned_by_installer(path, owner_home, owner_uid):
174
+ """True when the installing user owns the config. stat follows links.
175
+
176
+ A user-owned symlink to a root-owned file is rejected. Windows has no
177
+ trustworthy uid here, so the real path has to stay under the owner home.
178
+ """
179
+ if not path or not os.path.isabs(path):
180
+ return False
181
+ if sys.platform == "win32":
182
+ if not owner_home:
183
+ return False
184
+ try:
185
+ real = os.path.realpath(path)
186
+ home = os.path.realpath(owner_home)
187
+ return os.path.commonpath([real, home]) == home
188
+ except (OSError, ValueError):
189
+ return False
190
+ if owner_uid is None:
191
+ return False
192
+ try:
193
+ st = os.stat(path)
194
+ except OSError:
195
+ return False
196
+ return int(st.st_uid) == int(owner_uid)
197
+
198
+
199
+ def collect_candidates(argv_list, read_text, owner_home, mihomo_home, verge_paths, owner_uid=None):
200
+ """Ordered candidates. TCP comes only from a config the installer owns.
201
+
202
+ Process -ext-ctl addresses are not probed. Unix and pipe flags carry no secret.
203
+ """
204
+ found = []
205
+ seen = set()
206
+
207
+ def add(candidate):
208
+ key = (candidate.kind, candidate.address)
209
+ if key in seen:
210
+ return
211
+ seen.add(key)
212
+ found.append(candidate)
213
+
214
+ for argv in argv_list or []:
215
+ if not argv:
216
+ continue
217
+ flags = parse_flags(argv[1:])
218
+ program = os.path.basename(str(argv[0]).replace("\\", "/")).lower()
219
+ if "mihomo" not in program and "clash" not in program and not any(
220
+ name in flags for name in _CONTROLLER_FLAGS):
221
+ continue
222
+ source = "process " + (os.path.basename(str(argv[0]).replace("\\", "/")) or str(argv[0]))
223
+ home = flags.get("d")
224
+ if home is not None and not os.path.isabs(home):
225
+ home = None
226
+ config_path = None
227
+ if "f" in flags and os.path.isabs(flags["f"]):
228
+ config_path = flags["f"]
229
+ elif home is not None:
230
+ config_path = os.path.join(home, "config.yaml")
231
+ if config_path and config_owned_by_installer(config_path, owner_home, owner_uid):
232
+ _collect_config(add, config_path, home, None, source + " config", read_text)
233
+ if "ext-ctl-unix" in flags:
234
+ _add_unix(add, flags["ext-ctl-unix"], home, source)
235
+ if "ext-ctl-pipe" in flags:
236
+ add(Candidate(flags["ext-ctl-pipe"], "", source, "pipe"))
237
+ for path in verge_paths or []:
238
+ if config_owned_by_installer(path, owner_home, owner_uid):
239
+ _collect_config(add, path, os.path.dirname(path), None, "config " + path, read_text)
240
+ if mihomo_home:
241
+ sock = os.path.join(mihomo_home, "mihomo.sock")
242
+ add(Candidate("unix:" + sock, "", "mihomo-cli socket", "unix"))
243
+ return found
244
+
245
+
246
+ def _tcp_address(host, port):
247
+ if ":" in host:
248
+ return "http://[{}]:{}".format(host, port)
249
+ return "http://{}:{}".format(host, port)
250
+
251
+
252
+ def _add_unix(add, path, home, source):
253
+ if not os.path.isabs(path):
254
+ if not home:
255
+ return
256
+ path = os.path.join(home, path)
257
+ add(Candidate("unix:" + path, "", source, "unix"))
258
+
259
+
260
+ def _collect_config(add, path, home, secret_override, source, read_text):
261
+ text = read_text(path)
262
+ values = _read_config_text(text)
263
+ if values is None:
264
+ return ""
265
+ file_secret = values.get("secret", "")
266
+ if values.get("external-controller-unix"):
267
+ _add_unix(add, values["external-controller-unix"], home, source)
268
+ if values.get("external-controller-pipe"):
269
+ add(Candidate(values["external-controller-pipe"], "", source, "pipe"))
270
+ if values.get("external-controller"):
271
+ parsed = normalize_tcp(values["external-controller"])
272
+ if parsed:
273
+ _display, host, port = parsed
274
+ secret = secret_override if secret_override is not None else file_secret
275
+ add(Candidate(_tcp_address(host, port), secret, source, "tcp"))
276
+ return file_secret
277
+
278
+
279
+ def _read_config_text(text):
280
+ if text is None:
281
+ return None
282
+ values = {}
283
+ for line in text.splitlines():
284
+ match = _CONFIG_KEY.match(line)
285
+ if match and match.group(1) not in values:
286
+ values[match.group(1)] = _yaml_scalar(match.group(2))
287
+ return values
288
+
289
+
290
+ def _yaml_scalar(raw):
291
+ text = raw.strip()
292
+ if text[:1] == '"':
293
+ out = []
294
+ index = 1
295
+ while index < len(text) and text[index] != '"':
296
+ if text[index] == "\\" and index + 1 < len(text):
297
+ index += 1
298
+ out.append(text[index])
299
+ index += 1
300
+ return "".join(out)
301
+ if text[:1] == "'":
302
+ out = []
303
+ index = 1
304
+ while index < len(text):
305
+ if text[index] == "'":
306
+ if text[index + 1:index + 2] != "'":
307
+ break
308
+ index += 1
309
+ out.append(text[index])
310
+ index += 1
311
+ return "".join(out)
312
+ if text.startswith("#"):
313
+ return ""
314
+ return text.split(" #", 1)[0].strip()
315
+
316
+
317
+ def verge_paths(owner_home, appdata):
318
+ """macOS and Windows Verge paths only. Linux is not guessed. Root skips /root."""
319
+ if sys.platform == "darwin" and owner_home:
320
+ return [os.path.join(owner_home, "Library", "Application Support", VERGE_DIR, "config.yaml")]
321
+ if sys.platform == "win32" and appdata:
322
+ return [os.path.join(appdata, VERGE_DIR, "config.yaml")]
323
+ return []
324
+
325
+
326
+ def mihomo_home(owner_home, environ):
327
+ """MIHOMO_CLI_HOME only when already in the environment. Otherwise the owner home."""
328
+ explicit = (environ or {}).get("MIHOMO_CLI_HOME")
329
+ if explicit:
330
+ return explicit
331
+ if not owner_home:
332
+ return None
333
+ return os.path.join(owner_home, ".local", "share", "mihomo-cli")
334
+
335
+
336
+ def controller_port(address):
337
+ """TCP port of an http controller, or None for unix and pipes."""
338
+ if not address or not address.startswith("http://"):
339
+ return None
340
+ try:
341
+ parts = urllib.parse.urlsplit(address)
342
+ return parts.port
343
+ except ValueError:
344
+ return None
345
+
346
+
347
+ def select_port(data, controller_address):
348
+ port = effective_port(data)
349
+ check_lan_and_bind(data)
350
+ managed = controller_port(controller_address)
351
+ if managed is not None and port == managed:
352
+ raise DiscoverFailure("refusing the external-controller port as the proxy")
353
+ return port
354
+
355
+
356
+ def probe_candidate(candidate, fetch):
357
+ """fetch(address, secret) -> (status, body). Errors do not include the secret.
358
+
359
+ A broken controller may echo the secret inside parsed JSON. Do not log `data`.
360
+ """
361
+ secret = candidate.secret if candidate.kind == "tcp" else ""
362
+ try:
363
+ status, body = fetch(candidate.address, secret)
364
+ except DiscoverFailure:
365
+ raise
366
+ except Exception as exc:
367
+ message = "controller unreachable"
368
+ # Do not attach the exception text if it swallowed the secret.
369
+ if secret and secret in str(exc):
370
+ raise DiscoverFailure(message, secret=secret)
371
+ detail = str(exc)
372
+ if secret and secret in detail:
373
+ detail = detail.replace(secret, secret_label(secret))
374
+ raise DiscoverFailure("{}: {}".format(message, detail), secret=secret)
375
+ try:
376
+ data = interpret_response(status, body, secret)
377
+ except DiscoverFailure as exc:
378
+ text = str(exc)
379
+ if text == "needs a secret":
380
+ return None, log_line(candidate.address, candidate.source, "needs a secret")
381
+ if _is_fatal(text):
382
+ raise
383
+ return None, log_line(candidate.address, candidate.source, text)
384
+ try:
385
+ port = select_port(data, candidate.address)
386
+ except DiscoverFailure as exc:
387
+ text = str(exc)
388
+ if _is_fatal(text):
389
+ raise
390
+ return None, log_line(candidate.address, candidate.source, text)
391
+ # Unix and pipe candidates do not keep a file secret.
392
+ kept_secret = secret if candidate.kind == "tcp" else ""
393
+ return {
394
+ "port": port,
395
+ "proxy": proxy_url(port),
396
+ "controller": candidate.address,
397
+ "secret": kept_secret,
398
+ "source": candidate.source,
399
+ }, log_line(candidate.address, candidate.source, "mixed-port={}".format(port))
400
+
401
+
402
+ def controller_candidate(text, secret):
403
+ """One explicit --controller value. Loopback TCP, or a unix: path."""
404
+ raw = (text or "").strip()
405
+ if not raw:
406
+ raise UsageError("--controller needs a value")
407
+ if raw.startswith("unix:"):
408
+ return Candidate(raw, "", "--controller", "unix")
409
+ if "://" not in raw:
410
+ raw = "http://" + raw
411
+ parts = urllib.parse.urlsplit(raw)
412
+ host = parts.hostname
413
+ port = parts.port
414
+ if host is None or port is None:
415
+ raise UsageError("controller must be host:port or unix:PATH")
416
+ if not _is_loopback(host):
417
+ raise UsageError("controller must be loopback")
418
+ return Candidate(raw, secret or "", "--controller", "tcp")
419
+
420
+
421
+ def _port_only(port, note):
422
+ return {
423
+ "port": int(port),
424
+ "proxy": proxy_url(port),
425
+ "controller": "",
426
+ "secret": "",
427
+ "source": "--port",
428
+ "logs": [note],
429
+ }
430
+
431
+
432
+ def choose_port(forced_port, explicit_controller, candidates, fetch):
433
+ """Pick the mixed port.
434
+
435
+ An explicit --controller must succeed. Without one, --port is the fallback
436
+ when nothing answers. A successful read that disagrees with --port does not
437
+ install. allow-lan, a bad bind, SOCKS-only, and the controller port stay fatal.
438
+ """
439
+ if explicit_controller and not candidates:
440
+ raise DiscoverFailure("controller was not readable")
441
+ if not candidates:
442
+ if forced_port is None:
443
+ raise DiscoverFailure(
444
+ "could not read /configs; pass --port, or --controller and --secret-stdin")
445
+ return _port_only(forced_port, "warning: using --port; controller was not found")
446
+ try:
447
+ return discover(candidates, fetch, forced_port=forced_port)
448
+ except DiscoverFailure as exc:
449
+ text = str(exc)
450
+ fallback = (
451
+ forced_port is not None
452
+ and not explicit_controller
453
+ and not _is_fatal(text)
454
+ and "does not match" not in text
455
+ )
456
+ if not fallback:
457
+ raise
458
+ return _port_only(forced_port, "warning: " + text)
459
+
460
+
461
+ def discover(candidates, fetch, forced_port=None):
462
+ """Try candidates in order. The first JSON with a usable port wins.
463
+
464
+ Logs are a list of strings safe to print. `forced_port` cross-checks.
465
+ """
466
+ logs = []
467
+ winner = None
468
+ for candidate in candidates:
469
+ found, line = probe_candidate(candidate, fetch)
470
+ logs.append(line)
471
+ if found is not None and winner is None:
472
+ winner = found
473
+ break
474
+ if winner is None:
475
+ raise DiscoverFailure(
476
+ "could not read /configs; pass --port, or --controller and --secret-stdin")
477
+ if forced_port is not None and int(forced_port) != int(winner["port"]):
478
+ raise DiscoverFailure(
479
+ "--port {} does not match controller mixed-port {}".format(forced_port, winner["port"]))
480
+ winner["logs"] = logs
481
+ return winner
482
+
483
+
484
+ def http_get(address, secret, timeout=TIMEOUT, max_body=MAX_BODY):
485
+ """GET /configs. Returns (status, body). Raises DiscoverFailure without the secret."""
486
+ try:
487
+ if address.startswith("unix:"):
488
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
489
+ sock.settimeout(timeout)
490
+ sock.connect(address[len("unix:"):])
491
+ host_header = "localhost"
492
+ elif address.lower().startswith(PIPE_PREFIX.lower()) or address.startswith("\\\\.\\pipe\\"):
493
+ raise DiscoverFailure("named pipe controllers are not readable from this process", secret=secret)
494
+ else:
495
+ parts = urllib.parse.urlsplit(address)
496
+ host = parts.hostname
497
+ port = parts.port
498
+ if host is None or port is None:
499
+ raise DiscoverFailure("controller address has no host or port", secret=secret)
500
+ sock = socket.create_connection((host, port), timeout)
501
+ host_header = host
502
+ try:
503
+ header = "GET /configs HTTP/1.0\r\nHost: {}\r\nConnection: close\r\n".format(host_header)
504
+ if secret:
505
+ header += "Authorization: Bearer {}\r\n".format(secret)
506
+ header += "\r\n"
507
+ sock.sendall(header.encode("ascii", "strict"))
508
+ chunks = []
509
+ remaining = max_body + 8192
510
+ while remaining > 0:
511
+ block = sock.recv(min(4096, remaining))
512
+ if not block:
513
+ break
514
+ chunks.append(block)
515
+ remaining -= len(block)
516
+ finally:
517
+ sock.close()
518
+ except DiscoverFailure:
519
+ raise
520
+ except Exception as exc:
521
+ text = str(exc)
522
+ if secret and secret in text:
523
+ text = text.replace(secret, secret_label(secret))
524
+ raise DiscoverFailure("controller unreachable: {}".format(text), secret=secret)
525
+ raw = b"".join(chunks)
526
+ if len(raw) > max_body + 8192:
527
+ raw = raw[:max_body + 8192]
528
+ header_blob, _sep, body = raw.partition(b"\r\n\r\n")
529
+ if not header_blob.startswith(b"HTTP/"):
530
+ raise DiscoverFailure("controller did not return HTTP", secret=secret)
531
+ first = header_blob.split(b"\r\n", 1)[0].decode("ascii", "replace")
532
+ parts = first.split()
533
+ if len(parts) < 2 or not parts[1].isdigit():
534
+ raise DiscoverFailure("controller returned an unreadable status", secret=secret)
535
+ status = int(parts[1])
536
+ if len(body) > max_body:
537
+ body = body[:max_body]
538
+ return status, body
539
+
540
+
541
+ def default_fetch(address, secret):
542
+ return http_get(address, secret)