@kyan-du/agent-wechat-cli 0.12.0-next.0
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/README.md +86 -0
- package/dist/cli.js +9060 -0
- package/dist/device_identity.py +353 -0
- package/dist/image-compatibility.json +12 -0
- package/package.json +37 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Canonical host-side device identity. CLI and Compose both use this file."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import fcntl
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shlex
|
|
11
|
+
import stat
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from typing import Callable, Optional
|
|
17
|
+
|
|
18
|
+
PREFIXES = ("lenovo-pc", "honor-pc", "xiaomi-pc", "asus-pc", "dell-pc", "hp-pc", "thinkpad")
|
|
19
|
+
MACHINE_ID_RE = re.compile(r"^[0-9a-f]{32}$")
|
|
20
|
+
HOSTNAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$")
|
|
21
|
+
MAC_RE = re.compile(r"^[0-9a-f]{2}(:[0-9a-f]{2}){5}$")
|
|
22
|
+
ENV_NAME = "device-identity.env"
|
|
23
|
+
JSON_NAME = "device-identity.json"
|
|
24
|
+
LOCK_NAME = ".device-identity.lock"
|
|
25
|
+
ENV_TEMP_RE = re.compile(r"^device-identity\.env\.(?:\d+\.[0-9a-f]{8}|[A-Za-z0-9_-]+)$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def die(message: str) -> None:
|
|
29
|
+
print(f"[identity] {message}", file=sys.stderr)
|
|
30
|
+
raise SystemExit(1)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def valid_machine_id(value: str) -> bool:
|
|
34
|
+
return "\n" not in value and "\r" not in value and bool(MACHINE_ID_RE.fullmatch(value))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def valid_hostname(value: str) -> bool:
|
|
38
|
+
return (
|
|
39
|
+
"\n" not in value
|
|
40
|
+
and "\r" not in value
|
|
41
|
+
and 1 <= len(value) <= 63
|
|
42
|
+
and bool(HOSTNAME_RE.fullmatch(value))
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def valid_mac(value: str) -> bool:
|
|
47
|
+
if "\n" in value or "\r" in value or not MAC_RE.fullmatch(value):
|
|
48
|
+
return False
|
|
49
|
+
return int(value.split(":", 1)[0], 16) % 2 == 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def validate_override(name: str, value: Optional[str], checker: Callable[[str], bool]) -> Optional[str]:
|
|
53
|
+
if not value:
|
|
54
|
+
return None
|
|
55
|
+
if not checker(value):
|
|
56
|
+
die(f"invalid {name} override")
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def path_kind(path: str) -> str:
|
|
61
|
+
try:
|
|
62
|
+
info = os.lstat(path)
|
|
63
|
+
except FileNotFoundError:
|
|
64
|
+
return "absent"
|
|
65
|
+
if stat.S_ISLNK(info.st_mode):
|
|
66
|
+
return "symlink"
|
|
67
|
+
if stat.S_ISREG(info.st_mode):
|
|
68
|
+
return "file"
|
|
69
|
+
if stat.S_ISDIR(info.st_mode):
|
|
70
|
+
return "dir"
|
|
71
|
+
return "other"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def require_absent_or_regular(path: str) -> None:
|
|
75
|
+
kind = path_kind(path)
|
|
76
|
+
if kind == "symlink":
|
|
77
|
+
die(f"{path} is a symlink")
|
|
78
|
+
if kind == "other":
|
|
79
|
+
die(f"{path} is not a regular file")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def parse_env_identity(path: str) -> tuple[str, str, str]:
|
|
83
|
+
require_absent_or_regular(path)
|
|
84
|
+
data = open(path, "rb").read()
|
|
85
|
+
if b"\0" in data:
|
|
86
|
+
die(f"NUL in {path}")
|
|
87
|
+
try:
|
|
88
|
+
text = data.decode("ascii")
|
|
89
|
+
except UnicodeDecodeError:
|
|
90
|
+
die(f"non-ascii identity in {path}")
|
|
91
|
+
mid = hn = mac = None
|
|
92
|
+
for line in text.splitlines():
|
|
93
|
+
if line.startswith("AGENT_WECHAT_MACHINE_ID="):
|
|
94
|
+
if mid is not None:
|
|
95
|
+
die(f"duplicate AGENT_WECHAT_MACHINE_ID in {path}")
|
|
96
|
+
value = line[len("AGENT_WECHAT_MACHINE_ID=") :]
|
|
97
|
+
if not valid_machine_id(value):
|
|
98
|
+
die(f"invalid AGENT_WECHAT_MACHINE_ID in {path}")
|
|
99
|
+
mid = value
|
|
100
|
+
elif line.startswith("AGENT_WECHAT_HOSTNAME="):
|
|
101
|
+
if hn is not None:
|
|
102
|
+
die(f"duplicate AGENT_WECHAT_HOSTNAME in {path}")
|
|
103
|
+
value = line[len("AGENT_WECHAT_HOSTNAME=") :]
|
|
104
|
+
if not valid_hostname(value):
|
|
105
|
+
die(f"invalid AGENT_WECHAT_HOSTNAME in {path}")
|
|
106
|
+
hn = value
|
|
107
|
+
elif line.startswith("AGENT_WECHAT_MAC="):
|
|
108
|
+
if mac is not None:
|
|
109
|
+
die(f"duplicate AGENT_WECHAT_MAC in {path}")
|
|
110
|
+
value = line[len("AGENT_WECHAT_MAC=") :]
|
|
111
|
+
if not valid_mac(value):
|
|
112
|
+
die(f"invalid AGENT_WECHAT_MAC in {path}")
|
|
113
|
+
mac = value
|
|
114
|
+
else:
|
|
115
|
+
die(f"unexpected line in {path}")
|
|
116
|
+
if not (mid and hn and mac):
|
|
117
|
+
die(f"incomplete identity in {path}")
|
|
118
|
+
return mid, hn, mac
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def parse_json_identity(path: str) -> tuple[str, str, str]:
|
|
122
|
+
require_absent_or_regular(path)
|
|
123
|
+
raw = open(path, "rb").read()
|
|
124
|
+
if b"\0" in raw:
|
|
125
|
+
die(f"NUL in {path}")
|
|
126
|
+
try:
|
|
127
|
+
data = json.loads(raw.decode("utf-8"))
|
|
128
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
129
|
+
die(f"invalid JSON identity in {path}")
|
|
130
|
+
if not isinstance(data, dict):
|
|
131
|
+
die(f"invalid JSON identity in {path}")
|
|
132
|
+
mid = data.get("machineId")
|
|
133
|
+
hn = data.get("hostname")
|
|
134
|
+
mac = data.get("mac")
|
|
135
|
+
if not isinstance(mid, str) or not valid_machine_id(mid):
|
|
136
|
+
die(f"invalid machineId in {path}")
|
|
137
|
+
if not isinstance(hn, str) or not valid_hostname(hn):
|
|
138
|
+
die(f"invalid hostname in {path}")
|
|
139
|
+
if not isinstance(mac, str) or not valid_mac(mac):
|
|
140
|
+
die(f"invalid mac in {path}")
|
|
141
|
+
return mid, hn, mac
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def derive_hostname(mid: str) -> str:
|
|
145
|
+
idx = int(mid[0:2], 16) % len(PREFIXES)
|
|
146
|
+
num = int(mid[2:6], 16) % 900 + 100
|
|
147
|
+
return f"{PREFIXES[idx]}-{num}"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def derive_mac(mid: str) -> str:
|
|
151
|
+
return f"00:1b:21:{mid[6:8]}:{mid[8:10]}:{mid[10:12]}"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def exclusive_publish(
|
|
155
|
+
env_file: str,
|
|
156
|
+
mid: str,
|
|
157
|
+
hn: str,
|
|
158
|
+
mac: str,
|
|
159
|
+
after_link: Optional[Callable[[str], None]] = None,
|
|
160
|
+
) -> bool:
|
|
161
|
+
directory = os.path.dirname(env_file)
|
|
162
|
+
require_absent_or_regular(env_file)
|
|
163
|
+
fd, tmp = tempfile.mkstemp(prefix="device-identity.env.", dir=directory)
|
|
164
|
+
try:
|
|
165
|
+
os.fchmod(fd, 0o600)
|
|
166
|
+
payload = (
|
|
167
|
+
f"AGENT_WECHAT_MACHINE_ID={mid}\n"
|
|
168
|
+
f"AGENT_WECHAT_HOSTNAME={hn}\n"
|
|
169
|
+
f"AGENT_WECHAT_MAC={mac}\n"
|
|
170
|
+
).encode("ascii")
|
|
171
|
+
os.write(fd, payload)
|
|
172
|
+
os.fsync(fd)
|
|
173
|
+
finally:
|
|
174
|
+
os.close(fd)
|
|
175
|
+
try:
|
|
176
|
+
os.link(tmp, env_file)
|
|
177
|
+
except FileExistsError:
|
|
178
|
+
try:
|
|
179
|
+
os.unlink(tmp)
|
|
180
|
+
except FileNotFoundError:
|
|
181
|
+
pass
|
|
182
|
+
return False
|
|
183
|
+
if after_link is not None:
|
|
184
|
+
after_link(tmp)
|
|
185
|
+
try:
|
|
186
|
+
os.unlink(tmp)
|
|
187
|
+
except FileNotFoundError:
|
|
188
|
+
winner = parse_env_identity(env_file)
|
|
189
|
+
if winner != (mid, hn, mac):
|
|
190
|
+
raise
|
|
191
|
+
os.chmod(env_file, 0o600)
|
|
192
|
+
return True
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def harden_regular(path: str) -> None:
|
|
196
|
+
require_absent_or_regular(path)
|
|
197
|
+
flags = os.O_RDONLY
|
|
198
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
199
|
+
flags |= os.O_NOFOLLOW
|
|
200
|
+
fd = os.open(path, flags)
|
|
201
|
+
try:
|
|
202
|
+
info = os.fstat(fd)
|
|
203
|
+
if info.st_nlink > 1:
|
|
204
|
+
cleanup_owned_temp_links(path, info)
|
|
205
|
+
info = os.fstat(fd)
|
|
206
|
+
if info.st_nlink != 1:
|
|
207
|
+
die(f"{path} has unexpected link count {info.st_nlink}")
|
|
208
|
+
if info.st_uid != os.getuid() and os.getuid() != 0:
|
|
209
|
+
die(f"{path} is not owned by the current user")
|
|
210
|
+
os.fchmod(fd, 0o600)
|
|
211
|
+
finally:
|
|
212
|
+
os.close(fd)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def cleanup_owned_temp_links(path: str, target_info: os.stat_result) -> None:
|
|
216
|
+
directory = os.path.dirname(path)
|
|
217
|
+
base = os.path.basename(path)
|
|
218
|
+
dev, ino = target_info.st_dev, target_info.st_ino
|
|
219
|
+
progressed = True
|
|
220
|
+
while progressed:
|
|
221
|
+
progressed = False
|
|
222
|
+
for name in os.listdir(directory):
|
|
223
|
+
if name == base or not ENV_TEMP_RE.fullmatch(name):
|
|
224
|
+
continue
|
|
225
|
+
candidate = os.path.join(directory, name)
|
|
226
|
+
try:
|
|
227
|
+
info = os.lstat(candidate)
|
|
228
|
+
except FileNotFoundError:
|
|
229
|
+
continue
|
|
230
|
+
if stat.S_ISREG(info.st_mode) and info.st_dev == dev and info.st_ino == ino:
|
|
231
|
+
os.unlink(candidate)
|
|
232
|
+
progressed = True
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def acquire_lock(lock_path: str) -> int:
|
|
236
|
+
require_absent_or_regular(lock_path)
|
|
237
|
+
flags = os.O_RDWR | os.O_CREAT
|
|
238
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
239
|
+
flags |= os.O_NOFOLLOW
|
|
240
|
+
try:
|
|
241
|
+
fd = os.open(lock_path, flags, 0o600)
|
|
242
|
+
except OSError as exc:
|
|
243
|
+
die(f"cannot open lock {lock_path}: {exc}")
|
|
244
|
+
try:
|
|
245
|
+
info = os.fstat(fd)
|
|
246
|
+
if not stat.S_ISREG(info.st_mode):
|
|
247
|
+
die(f"{lock_path} is not a regular file")
|
|
248
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
249
|
+
except Exception:
|
|
250
|
+
os.close(fd)
|
|
251
|
+
raise
|
|
252
|
+
return fd
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def prepare_ident_dir(ident_dir: str) -> None:
|
|
256
|
+
kind = path_kind(ident_dir)
|
|
257
|
+
if kind == "symlink":
|
|
258
|
+
die(f"{ident_dir} is a symlink")
|
|
259
|
+
if kind == "file":
|
|
260
|
+
die(f"{ident_dir} is not a directory")
|
|
261
|
+
if kind == "absent":
|
|
262
|
+
os.makedirs(ident_dir, mode=0o700, exist_ok=True)
|
|
263
|
+
elif kind != "dir":
|
|
264
|
+
die(f"{ident_dir} is not a directory")
|
|
265
|
+
try:
|
|
266
|
+
os.chmod(ident_dir, 0o700)
|
|
267
|
+
except OSError:
|
|
268
|
+
pass
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def load_or_create(ident_dir: str) -> tuple[str, str, str]:
|
|
272
|
+
prepare_ident_dir(ident_dir)
|
|
273
|
+
env_file = os.path.join(ident_dir, ENV_NAME)
|
|
274
|
+
json_file = os.path.join(ident_dir, JSON_NAME)
|
|
275
|
+
require_absent_or_regular(env_file)
|
|
276
|
+
require_absent_or_regular(json_file)
|
|
277
|
+
|
|
278
|
+
env_id = parse_env_identity(env_file) if path_kind(env_file) == "file" else None
|
|
279
|
+
json_id = parse_json_identity(json_file) if path_kind(json_file) == "file" else None
|
|
280
|
+
if env_id and json_id and env_id != json_id:
|
|
281
|
+
die(f"conflicting {ENV_NAME} and {JSON_NAME} in {ident_dir}")
|
|
282
|
+
if env_id:
|
|
283
|
+
harden_regular(env_file)
|
|
284
|
+
return env_id
|
|
285
|
+
if json_id:
|
|
286
|
+
if exclusive_publish(env_file, *json_id):
|
|
287
|
+
return parse_env_identity(env_file)
|
|
288
|
+
env_id = parse_env_identity(env_file)
|
|
289
|
+
if env_id != json_id:
|
|
290
|
+
die(f"conflicting {ENV_NAME} and {JSON_NAME} in {ident_dir}")
|
|
291
|
+
return env_id
|
|
292
|
+
|
|
293
|
+
mid = hn = mac = None
|
|
294
|
+
if os.environ.get("AGENT_WECHAT_IDENTITY_FROM_ENV") == "1":
|
|
295
|
+
mid = validate_override(
|
|
296
|
+
"AGENT_WECHAT_MACHINE_ID",
|
|
297
|
+
os.environ.get("AGENT_WECHAT_MACHINE_ID"),
|
|
298
|
+
valid_machine_id,
|
|
299
|
+
)
|
|
300
|
+
hn = validate_override(
|
|
301
|
+
"AGENT_WECHAT_HOSTNAME",
|
|
302
|
+
os.environ.get("AGENT_WECHAT_HOSTNAME"),
|
|
303
|
+
valid_hostname,
|
|
304
|
+
)
|
|
305
|
+
mac = validate_override(
|
|
306
|
+
"AGENT_WECHAT_MAC",
|
|
307
|
+
os.environ.get("AGENT_WECHAT_MAC"),
|
|
308
|
+
valid_mac,
|
|
309
|
+
)
|
|
310
|
+
if mid is None:
|
|
311
|
+
mid = uuid.uuid4().hex
|
|
312
|
+
if not valid_machine_id(mid):
|
|
313
|
+
die("generated machine-id is invalid")
|
|
314
|
+
if hn is None:
|
|
315
|
+
hn = derive_hostname(mid)
|
|
316
|
+
if mac is None:
|
|
317
|
+
mac = derive_mac(mid)
|
|
318
|
+
if not valid_hostname(hn):
|
|
319
|
+
die("generated hostname is invalid")
|
|
320
|
+
if not valid_mac(mac):
|
|
321
|
+
die("generated MAC is invalid")
|
|
322
|
+
if exclusive_publish(env_file, mid, hn, mac):
|
|
323
|
+
return parse_env_identity(env_file)
|
|
324
|
+
return parse_env_identity(env_file)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def main() -> None:
|
|
328
|
+
ident_dir = (
|
|
329
|
+
sys.argv[1]
|
|
330
|
+
if len(sys.argv) > 1
|
|
331
|
+
else os.environ.get("AGENT_WECHAT_IDENTITY_DIR")
|
|
332
|
+
or os.path.join(os.path.expanduser("~"), ".config", "agent-wechat")
|
|
333
|
+
)
|
|
334
|
+
prepare_ident_dir(ident_dir)
|
|
335
|
+
lock_path = os.path.join(ident_dir, LOCK_NAME)
|
|
336
|
+
lock_fd = acquire_lock(lock_path)
|
|
337
|
+
try:
|
|
338
|
+
hold_ms = int(os.environ.get("AGENT_WECHAT_IDENTITY_HOLD_MS") or "0")
|
|
339
|
+
except ValueError:
|
|
340
|
+
hold_ms = 0
|
|
341
|
+
try:
|
|
342
|
+
mid, hn, mac = load_or_create(ident_dir)
|
|
343
|
+
if hold_ms > 0:
|
|
344
|
+
time.sleep(hold_ms / 1000.0)
|
|
345
|
+
finally:
|
|
346
|
+
os.close(lock_fd)
|
|
347
|
+
print(f"export AGENT_WECHAT_MACHINE_ID={shlex.quote(mid)}")
|
|
348
|
+
print(f"export AGENT_WECHAT_HOSTNAME={shlex.quote(hn)}")
|
|
349
|
+
print(f"export AGENT_WECHAT_MAC={shlex.quote(mac)}")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
if __name__ == "__main__":
|
|
353
|
+
main()
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kyan-du/agent-wechat-cli",
|
|
3
|
+
"version": "0.12.0-next.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"wx": "./dist/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"!dist/.release-metafile.json"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "node build.js",
|
|
14
|
+
"typecheck": "tsc --noEmit",
|
|
15
|
+
"test": "node --test --experimental-strip-types src/*.test.ts"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"qrcode-terminal": "^0.12.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@kyan-du/agent-wechat-shared": "workspace:*",
|
|
22
|
+
"commander": "^14.0.3",
|
|
23
|
+
"esbuild": "^0.25.0",
|
|
24
|
+
"@types/node": "^22",
|
|
25
|
+
"@types/qrcode-terminal": "^0.12.2",
|
|
26
|
+
"typescript": "^5.4.5"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public",
|
|
30
|
+
"provenance": true
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/kyan-du/agent-wechat.git",
|
|
35
|
+
"directory": "packages/cli"
|
|
36
|
+
}
|
|
37
|
+
}
|