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.
- {wechatbridge_cli-1.3.2/wechatbridge_cli.egg-info → wechatbridge_cli-1.3.3}/PKG-INFO +1 -1
- wechatbridge_cli-1.3.3/tests/test_hardening.py +314 -0
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge/__init__.py +1 -1
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge/ilink.py +24 -6
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge/main.py +7 -1
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge/runner_common.py +76 -5
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3/wechatbridge_cli.egg-info}/PKG-INFO +1 -1
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge_cli.egg-info/SOURCES.txt +1 -0
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/LICENSE +0 -0
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/README.md +0 -0
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/pyproject.toml +0 -0
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/setup.cfg +0 -0
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge/__main__.py +0 -0
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge/agy.py +0 -0
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge/config.py +0 -0
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge/grok.py +0 -0
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge/update_check.py +0 -0
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge_cli.egg-info/requires.txt +0 -0
- {wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge_cli.egg-info/top_level.txt +0 -0
|
@@ -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
|
+
__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
|
|
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
|
|
648
|
-
|
|
649
|
-
|
|
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,
|
|
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=
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{wechatbridge_cli-1.3.2 → wechatbridge_cli-1.3.3}/wechatbridge_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|