wechatbridge-cli 1.3.2__tar.gz → 1.3.3__tar.gz

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.
Files changed (21) hide show
  1. {wechatbridge_cli-1.3.2/wechatbridge_cli.egg-info → wechatbridge_cli-1.3.3}/PKG-INFO +1 -1
  2. wechatbridge_cli-1.3.3/tests/test_hardening.py +314 -0
  3. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge/__init__.py +1 -1
  4. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge/ilink.py +24 -6
  5. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge/main.py +7 -1
  6. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge/runner_common.py +76 -5
  7. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3/wechatbridge_cli.egg-info}/PKG-INFO +1 -1
  8. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge_cli.egg-info/SOURCES.txt +1 -0
  9. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/LICENSE +0 -0
  10. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/README.md +0 -0
  11. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/pyproject.toml +0 -0
  12. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/setup.cfg +0 -0
  13. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge/__main__.py +0 -0
  14. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge/agy.py +0 -0
  15. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge/config.py +0 -0
  16. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge/grok.py +0 -0
  17. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge/update_check.py +0 -0
  18. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
  19. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
  20. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge_cli.egg-info/requires.txt +0 -0
  21. {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wechatbridge-cli
3
- Version: 1.3.2
3
+ Version: 1.3.3
4
4
  Summary: Bridge WeChat messages to agy or Grok Build CLIs — text/image/file/voice in, CLI replies and generated files back.
5
5
  Author: WeChatBridge contributors
6
6
  License: MIT
@@ -0,0 +1,314 @@
1
+ """Hardening / cleanup / message-safety probes (stdlib unittest only).
2
+
3
+ Exercises real production helpers and async methods with mocks — not
4
+ string-copied stand-ins of the user-facing copy.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import os
11
+ import tempfile
12
+ import time
13
+ import unittest
14
+ from unittest import mock
15
+ from unittest.mock import AsyncMock, MagicMock
16
+
17
+
18
+ class TestSplitMessageChunks(unittest.TestCase):
19
+ def test_join_equals_original_fixed(self):
20
+ from wechatbridge.runner_common import split_message_chunks
21
+
22
+ samples = [
23
+ "",
24
+ "short",
25
+ "a" * 50,
26
+ "line1\nline2\nline3",
27
+ "word " * 400,
28
+ "x" * 10 + "\n" + "y" * 10,
29
+ " keep spaces at edges ",
30
+ "\n\n\n",
31
+ "中文" * 300,
32
+ ]
33
+ for text in samples:
34
+ for limit in (5, 20, 80, 2000):
35
+ chunks = split_message_chunks(text, limit)
36
+ self.assertEqual("".join(chunks), text, msg=repr(text[:40]))
37
+ for c in chunks:
38
+ self.assertLessEqual(len(c), limit if limit > 0 else len(c))
39
+
40
+ def test_random_join_equals_original(self):
41
+ import random
42
+ from wechatbridge.runner_common import split_message_chunks
43
+
44
+ rng = random.Random(42)
45
+ alphabet = "abc \n中文,。"
46
+ for _ in range(30):
47
+ text = "".join(rng.choice(alphabet) for _ in range(rng.randint(0, 500)))
48
+ limit = rng.randint(1, 120)
49
+ chunks = split_message_chunks(text, limit)
50
+ self.assertEqual("".join(chunks), text)
51
+
52
+
53
+ class TestPathIsUnder(unittest.TestCase):
54
+ def test_child_and_escape(self):
55
+ from wechatbridge.runner_common import path_is_under
56
+
57
+ with tempfile.TemporaryDirectory() as td:
58
+ child = os.path.join(td, "a", "b")
59
+ os.makedirs(child)
60
+ self.assertTrue(path_is_under(child, td))
61
+ self.assertTrue(path_is_under(td, td))
62
+ outside = os.path.join(
63
+ tempfile.gettempdir(), "not-under-" + os.path.basename(td)
64
+ )
65
+ self.assertFalse(path_is_under(outside, td))
66
+
67
+ def test_symlink_escape_blocked(self):
68
+ from wechatbridge.runner_common import path_is_under
69
+
70
+ with tempfile.TemporaryDirectory() as td:
71
+ allowed = os.path.join(td, "allowed")
72
+ secret = os.path.join(td, "secret")
73
+ os.makedirs(allowed)
74
+ os.makedirs(secret)
75
+ leak = os.path.join(allowed, "leak")
76
+ os.symlink(secret, leak)
77
+ self.assertFalse(path_is_under(leak, allowed))
78
+
79
+
80
+ class TestRemoveOldFilesDangling(unittest.TestCase):
81
+ def test_dangling_file_and_dir_links_removed_immediately(self):
82
+ from wechatbridge.runner_common import _remove_old_files_under
83
+
84
+ with tempfile.TemporaryDirectory() as td:
85
+ bad_file = os.path.join(td, "python")
86
+ os.symlink("/no/such/target/python-xyz", bad_file)
87
+ bad_dir = os.path.join(td, "lib64")
88
+ os.symlink("/no/such/lib", bad_dir)
89
+ keep = os.path.join(td, "keep.txt")
90
+ with open(keep, "w", encoding="utf-8") as f:
91
+ f.write("ok")
92
+ old = os.path.join(td, "old.txt")
93
+ with open(old, "w", encoding="utf-8") as f:
94
+ f.write("bye")
95
+ old_mtime = time.time() - 10 * 86400
96
+ os.utime(old, (old_mtime, old_mtime))
97
+
98
+ cutoff = time.time() - 7 * 86400
99
+ removed = _remove_old_files_under(td, cutoff)
100
+
101
+ self.assertGreaterEqual(removed, 3)
102
+ self.assertFalse(os.path.lexists(bad_file))
103
+ self.assertFalse(os.path.lexists(bad_dir))
104
+ self.assertFalse(os.path.exists(old))
105
+ self.assertTrue(os.path.exists(keep))
106
+
107
+ def test_intact_young_symlink_kept(self):
108
+ from wechatbridge.runner_common import _remove_old_files_under
109
+
110
+ with tempfile.TemporaryDirectory() as td:
111
+ target = os.path.join(td, "real.txt")
112
+ with open(target, "w", encoding="utf-8") as f:
113
+ f.write("data")
114
+ link = os.path.join(td, "alias")
115
+ os.symlink(target, link)
116
+ cutoff = time.time() - 7 * 86400
117
+ _remove_old_files_under(td, cutoff)
118
+ self.assertTrue(os.path.lexists(link))
119
+ self.assertTrue(os.path.exists(target))
120
+
121
+ def test_does_not_follow_symlink_into_outside_tree(self):
122
+ """followlinks=False: must not age-delete files outside root via symlink."""
123
+ from wechatbridge.runner_common import _remove_old_files_under
124
+
125
+ with tempfile.TemporaryDirectory() as outer:
126
+ with tempfile.TemporaryDirectory() as root:
127
+ victim = os.path.join(outer, "outside-old.txt")
128
+ with open(victim, "w", encoding="utf-8") as f:
129
+ f.write("keep-me")
130
+ old_mtime = time.time() - 10 * 86400
131
+ os.utime(victim, (old_mtime, old_mtime))
132
+ os.symlink(outer, os.path.join(root, "escape"))
133
+ cutoff = time.time() - 7 * 86400
134
+ _remove_old_files_under(root, cutoff)
135
+ self.assertTrue(os.path.exists(victim), "must not delete outside tree")
136
+
137
+
138
+ class TestOversizedArtifactNotice(unittest.TestCase):
139
+ def test_helper_never_embeds_absolute_path(self):
140
+ from wechatbridge.runner_common import format_oversized_artifact_notice
141
+
142
+ art_path = "/root/.local/share/wechatbridge/default/sessions/u1/scratch/report.pdf"
143
+ text = format_oversized_artifact_notice("report.pdf", 120.5)
144
+ self.assertNotIn(art_path, text)
145
+ self.assertNotIn("/root/", text)
146
+ self.assertNotIn("sessions/", text)
147
+ self.assertIn("report.pdf", text)
148
+ self.assertIn("未回传", text)
149
+
150
+ def test_send_artifacts_back_uses_safe_notice(self):
151
+ """Call the real async helper; assert user text has no server path."""
152
+ from wechatbridge.main import send_artifacts_back
153
+
154
+ async def _run():
155
+ with tempfile.TemporaryDirectory() as td:
156
+ scratch = os.path.join(td, ".gemini", "antigravity-cli", "scratch")
157
+ os.makedirs(scratch)
158
+ big_path = os.path.join(scratch, "huge.bin")
159
+ with open(big_path, "wb") as f:
160
+ f.write(b"x" * 64)
161
+
162
+ client = MagicMock()
163
+ client.state.baseurl = "https://example.test"
164
+ client.state.bot_token = "tok"
165
+ client.send_message = AsyncMock(return_value=True)
166
+ client.send_media = AsyncMock(return_value=True)
167
+
168
+ with mock.patch(
169
+ "wechatbridge.main.get_session_dir", return_value=td
170
+ ), mock.patch(
171
+ "wechatbridge.main._get_backend", return_value="agy"
172
+ ), mock.patch(
173
+ "wechatbridge.main.config"
174
+ ) as cfg:
175
+ cfg.max_outbound_file_bytes = 8 # force oversized
176
+ await send_artifacts_back(
177
+ client,
178
+ "user-1",
179
+ "ctx-token",
180
+ [("huge.bin", big_path)],
181
+ )
182
+
183
+ client.send_media.assert_not_awaited()
184
+ client.send_message.assert_awaited()
185
+ kwargs = client.send_message.await_args.kwargs
186
+ text = kwargs["text"]
187
+ self.assertNotIn(big_path, text)
188
+ self.assertNotIn(td, text)
189
+ self.assertIn("huge.bin", text)
190
+ self.assertIn("未回传", text)
191
+
192
+ asyncio.run(_run())
193
+
194
+
195
+ class TestILinkDeliveryAccepted(unittest.TestCase):
196
+ def test_predicate(self):
197
+ from wechatbridge.ilink import ilink_delivery_accepted
198
+
199
+ self.assertTrue(ilink_delivery_accepted(0, ""))
200
+ self.assertTrue(ilink_delivery_accepted(0, None))
201
+ self.assertTrue(ilink_delivery_accepted(-1, "7487118974343175304"))
202
+ self.assertTrue(ilink_delivery_accepted(1, "abc"))
203
+ self.assertTrue(ilink_delivery_accepted(-1, 42)) # non-empty non-str id
204
+ self.assertFalse(ilink_delivery_accepted(-1, ""))
205
+ self.assertFalse(ilink_delivery_accepted(-1, " "))
206
+ self.assertFalse(ilink_delivery_accepted(1, None))
207
+ self.assertFalse(ilink_delivery_accepted(1, 0))
208
+
209
+
210
+ class TestILinkPostSendmessageRetry(unittest.IsolatedAsyncioTestCase):
211
+ async def asyncSetUp(self):
212
+ from wechatbridge.ilink import ILinkClient
213
+
214
+ self.client = ILinkClient()
215
+
216
+ async def asyncTearDown(self):
217
+ await self.client.http_client.aclose()
218
+
219
+ async def test_ret_minus_one_with_message_id_is_success(self):
220
+ mock_resp = MagicMock()
221
+ mock_resp.raise_for_status = MagicMock()
222
+ mock_resp.json.return_value = {"ret": -1, "message_id": "mid-ok-1"}
223
+ self.client.http_client.post = AsyncMock(return_value=mock_resp)
224
+
225
+ ok = await self.client._post_sendmessage_with_retry(
226
+ url="https://example.test/send",
227
+ headers={},
228
+ body={},
229
+ to_user_id="u1",
230
+ max_attempts=1,
231
+ )
232
+ self.assertTrue(ok)
233
+ self.client.http_client.post.assert_awaited_once()
234
+
235
+ async def test_ret_nonzero_without_message_id_fails_fast(self):
236
+ mock_resp = MagicMock()
237
+ mock_resp.raise_for_status = MagicMock()
238
+ mock_resp.json.return_value = {"ret": -1, "message_id": ""}
239
+ self.client.http_client.post = AsyncMock(return_value=mock_resp)
240
+
241
+ with mock.patch("wechatbridge.ilink.asyncio.sleep", new_callable=AsyncMock):
242
+ ok = await self.client._post_sendmessage_with_retry(
243
+ url="https://example.test/send",
244
+ headers={},
245
+ body={},
246
+ to_user_id="u1",
247
+ max_attempts=2,
248
+ )
249
+ self.assertFalse(ok)
250
+ self.assertEqual(self.client.http_client.post.await_count, 2)
251
+
252
+ async def test_ret_zero_succeeds(self):
253
+ mock_resp = MagicMock()
254
+ mock_resp.raise_for_status = MagicMock()
255
+ mock_resp.json.return_value = {"ret": 0, "message_id": "m0"}
256
+ self.client.http_client.post = AsyncMock(return_value=mock_resp)
257
+ ok = await self.client._post_sendmessage_with_retry(
258
+ url="https://example.test/send",
259
+ headers={},
260
+ body={},
261
+ to_user_id="u1",
262
+ max_attempts=1,
263
+ )
264
+ self.assertTrue(ok)
265
+
266
+
267
+ class TestInboundStreamCapLogic(unittest.TestCase):
268
+ def test_content_length_and_stream_abort_rules(self):
269
+ max_in = 100
270
+
271
+ def reject_cl(declared: int | None) -> bool:
272
+ return declared is not None and declared > max_in
273
+
274
+ def reject_buf(buf_len: int, piece_len: int) -> bool:
275
+ return buf_len + piece_len > max_in
276
+
277
+ self.assertTrue(reject_cl(101))
278
+ self.assertFalse(reject_cl(100))
279
+ self.assertFalse(reject_cl(None))
280
+ self.assertTrue(reject_buf(90, 20))
281
+ self.assertFalse(reject_buf(90, 10))
282
+
283
+
284
+ class TestSensitiveEnv(unittest.TestCase):
285
+ def test_strips_api_keys_keeps_harmless(self):
286
+ from wechatbridge.runner_common import _is_sensitive_env_name, sanitize_env
287
+
288
+ self.assertTrue(_is_sensitive_env_name("XAI_API_KEY"))
289
+ self.assertTrue(_is_sensitive_env_name("OPENAI_API_KEY"))
290
+ self.assertTrue(_is_sensitive_env_name("BOT_TOKEN"))
291
+ self.assertFalse(_is_sensitive_env_name("HOME"))
292
+ self.assertFalse(_is_sensitive_env_name("PATH"))
293
+ self.assertFalse(_is_sensitive_env_name("LANG"))
294
+
295
+ with tempfile.TemporaryDirectory() as td:
296
+ with mock.patch.dict(
297
+ os.environ,
298
+ {
299
+ "PATH": "/usr/bin",
300
+ "XAI_API_KEY": "secret",
301
+ "LANG": "C",
302
+ "HOME": "/tmp/other",
303
+ },
304
+ clear=False,
305
+ ):
306
+ env = sanitize_env(td)
307
+ self.assertEqual(env["HOME"], td)
308
+ self.assertNotIn("XAI_API_KEY", env)
309
+ self.assertIn("LANG", env)
310
+ self.assertEqual(env["LANG"], "C")
311
+
312
+
313
+ if __name__ == "__main__":
314
+ unittest.main()
@@ -1,2 +1,2 @@
1
1
  """WeChatBridge — bridge WeChat messages to agy or Grok Build CLIs."""
2
- __version__ = "1.3.2"
2
+ __version__ = "1.3.3"
@@ -26,6 +26,22 @@ logger = logging.getLogger("ilink")
26
26
  ILINK_BASE = config.ilink_base_url.rstrip("/")
27
27
 
28
28
 
29
+ def ilink_delivery_accepted(ret, message_id) -> bool:
30
+ """Whether an iLink sendmessage JSON body means the message was accepted.
31
+
32
+ The server often returns ``ret != 0`` (commonly ``-1``) **together with** a
33
+ non-empty ``message_id`` when delivery succeeded. Treat as failure only when
34
+ there is no message id to prove acceptance. ``ret == 0`` always accepts.
35
+ """
36
+ if ret == 0:
37
+ return True
38
+ if message_id is None:
39
+ return False
40
+ if isinstance(message_id, str):
41
+ return bool(message_id.strip())
42
+ return bool(message_id)
43
+
44
+
29
45
  def _is_allowed_media_url(url: str) -> bool:
30
46
  """Only allow downloads from WeChat CDN hosts or the configured CDN base host."""
31
47
  try:
@@ -633,7 +649,7 @@ class ILinkClient:
633
649
 
634
650
  ret = data.get("ret", -1)
635
651
  message_id = data.get("message_id", "")
636
- if ret != 0 and not message_id:
652
+ if not ilink_delivery_accepted(ret, message_id):
637
653
  logger.warning(
638
654
  "%s failed ret=%s for %s (attempt %d/%d): %s",
639
655
  log_label, ret, to_user_id, attempt, max_attempts, data,
@@ -644,14 +660,16 @@ class ILinkClient:
644
660
  continue
645
661
  return False
646
662
 
647
- if ret != 0 and message_id:
648
- logger.warning(
649
- "%s ret=%s but has message_id=%s, treating as success",
663
+ if ret != 0:
664
+ # Documented API quirk — delivered despite non-zero ret
665
+ logger.debug(
666
+ "%s ret=%s with message_id=%s (iLink quirk, delivered)",
650
667
  log_label, ret, message_id,
651
668
  )
652
669
  logger.info(
653
- "%s sent to %s (attempt %d/%d): msg_id=%s",
654
- log_label, to_user_id, attempt, max_attempts, data.get("message_id"),
670
+ "%s sent to %s (attempt %d/%d): msg_id=%s ret=%s",
671
+ log_label, to_user_id, attempt, max_attempts,
672
+ message_id, ret,
655
673
  )
656
674
  return True
657
675
 
@@ -22,6 +22,7 @@ from .runner_common import (
22
22
  clean_session_media,
23
23
  format_error,
24
24
  format_model_label,
25
+ format_oversized_artifact_notice,
25
26
  get_session_dir,
26
27
  is_dangerous,
27
28
  load_prefs,
@@ -380,9 +381,14 @@ async def send_artifacts_back(client, from_user, context_token, artifacts) -> No
380
381
  file_size = os.path.getsize(art_path)
381
382
  if file_size > config.max_outbound_file_bytes:
382
383
  size_mb = file_size / (1024 * 1024)
384
+ # Never echo server absolute paths to WeChat users
385
+ logger.info(
386
+ "Artifact too large (%.1f MB), kept on server: %s",
387
+ size_mb, art_path,
388
+ )
383
389
  await client.send_message(
384
390
  to_user_id=from_user,
385
- text=f"⚠️ **文件过大** ⚠️\n\n`{art_name}` {size_mb:.1f} MB\n已存:`{art_path}`",
391
+ text=format_oversized_artifact_notice(art_name, size_mb),
386
392
  context_token=context_token,
387
393
  baseurl=client.state.baseurl,
388
394
  bot_token=client.state.bot_token,
@@ -13,6 +13,7 @@ import os
13
13
  import re
14
14
  import shutil
15
15
  import signal
16
+ import stat
16
17
  import sys
17
18
  import time
18
19
 
@@ -169,6 +170,19 @@ def format_error(title: str, detail: str = "") -> str:
169
170
  return f"❌ **{title}** ❌"
170
171
 
171
172
 
173
+ def format_oversized_artifact_notice(art_name: str, size_mb: float) -> str:
174
+ """User-facing text when an artifact is too large to send back to WeChat.
175
+
176
+ Must never include server absolute paths — only the display name and size.
177
+ """
178
+ name = (art_name or "file").replace("`", "'").strip() or "file"
179
+ return (
180
+ f"⚠️ **文件过大** ⚠️\n\n"
181
+ f"`{name}` {float(size_mb):.1f} MB\n"
182
+ f"已保存在服务器会话目录,未回传微信。"
183
+ )
184
+
185
+
172
186
  def format_cli_error(raw_message: str, *, backend: str = "") -> str:
173
187
  """Map CLI stderr/JSON error text into Chinese title + fixed header.
174
188
 
@@ -616,34 +630,91 @@ _SESSION_HISTORY_REL_DIRS = (
616
630
  _SQLITE_SIDECAR_TAILS = ("-wal", "-shm", "-journal")
617
631
 
618
632
 
633
+ def _is_dangling_symlink(path: str) -> bool:
634
+ """True if path is a symlink whose target does not resolve.
635
+
636
+ Uses ``lstat`` first so we never mistake a plain missing path for a link.
637
+ ``os.path.exists`` follows the final hop — False means dangling. Unlink
638
+ still tolerates races (ENOENT) in the caller.
639
+ """
640
+ try:
641
+ st = os.lstat(path)
642
+ except OSError:
643
+ return False
644
+ if not stat.S_ISLNK(st.st_mode):
645
+ return False
646
+ try:
647
+ return not os.path.exists(path)
648
+ except OSError:
649
+ return True
650
+
651
+
652
+ def _unlink_quiet(path: str) -> bool:
653
+ """Unlink path; True if removed. ENOENT (race) counts as already gone."""
654
+ try:
655
+ os.unlink(path)
656
+ return True
657
+ except FileNotFoundError:
658
+ return True
659
+ except OSError as e:
660
+ logger.warning("Session cleanup failed %s: %s", path, e)
661
+ return False
662
+
663
+
619
664
  def _remove_old_files_under(root: str, cutoff: float) -> int:
620
665
  """Delete files under root older than cutoff; prune empty subdirs.
621
666
 
622
667
  For independent temp files only (images/cache/scratch). Does not remove
623
668
  ``root`` itself. Returns number of files removed.
669
+
670
+ Dangling symlinks (file or dir links, e.g. stale venv ``bin/python`` /
671
+ ``lib64``) are removed immediately — they are never useful and previously
672
+ caused getmtime noise. Intact files/links still age out by ``lstat`` mtime.
624
673
  """
625
674
  if not os.path.isdir(root):
626
675
  return 0
627
676
  removed = 0
628
677
  try:
629
- for dirpath, dirnames, filenames in os.walk(root, topdown=False):
678
+ # followlinks=False: never walk through session symlinks into host trees
679
+ for dirpath, dirnames, filenames in os.walk(
680
+ root, topdown=False, followlinks=False
681
+ ):
682
+ # Dir symlinks (and some platforms' file links) may appear in dirnames
683
+ for dn in list(dirnames):
684
+ dpath = os.path.join(dirpath, dn)
685
+ if _is_dangling_symlink(dpath):
686
+ if _unlink_quiet(dpath):
687
+ removed += 1
688
+ logger.info(
689
+ "Session cleanup: removed dangling dir link %s", dpath
690
+ )
630
691
  for fn in filenames:
631
692
  path = os.path.join(dirpath, fn)
632
693
  try:
633
- if not os.path.isfile(path) and not os.path.islink(path):
694
+ if os.path.islink(path):
695
+ # 悬空链接立刻删;完好链接仍按自身 mtime 过期
696
+ if _is_dangling_symlink(path) or os.lstat(path).st_mtime < cutoff:
697
+ if _unlink_quiet(path):
698
+ removed += 1
699
+ logger.info("Session cleanup: removed %s", path)
700
+ continue
701
+ if not os.path.isfile(path):
634
702
  continue
635
- # lstat 不跟随符号链接:悬空链接(如旧 venv 的 bin/python)
636
- # 也能按链接自身 mtime 正常过期,不再每次刷 warning
637
703
  if os.lstat(path).st_mtime < cutoff:
638
704
  os.remove(path)
639
705
  removed += 1
640
706
  logger.info("Session cleanup: removed %s", path)
707
+ except FileNotFoundError:
708
+ # raced with another cleaner / process
709
+ continue
641
710
  except OSError as e:
642
711
  logger.warning("Session cleanup failed %s: %s", path, e)
643
712
  if dirpath == root:
644
713
  continue
645
714
  try:
646
- if not os.listdir(dirpath):
715
+ # With followlinks=False, dirpath is a real dir we entered — only
716
+ # prune when empty (never follow/remove host trees via symlinks).
717
+ if not os.path.islink(dirpath) and not os.listdir(dirpath):
647
718
  os.rmdir(dirpath)
648
719
  except OSError:
649
720
  pass
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wechatbridge-cli
3
- Version: 1.3.2
3
+ Version: 1.3.3
4
4
  Summary: Bridge WeChat messages to agy or Grok Build CLIs — text/image/file/voice in, CLI replies and generated files back.
5
5
  Author: WeChatBridge contributors
6
6
  License: MIT
@@ -1,6 +1,7 @@
1
1
  LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
+ tests/test_hardening.py
4
5
  wechatbridge/__init__.py
5
6
  wechatbridge/__main__.py
6
7
  wechatbridge/agy.py