wechatbridge-cli 1.3.1__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.1/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.1 → wechatbridge_cli-1.3.3}/wechatbridge/__init__.py +1 -1
- {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.3}/wechatbridge/agy.py +45 -19
- {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.3}/wechatbridge/grok.py +45 -24
- {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.3}/wechatbridge/ilink.py +42 -20
- {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.3}/wechatbridge/main.py +173 -19
- {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.3}/wechatbridge/runner_common.py +88 -8
- {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.3/wechatbridge_cli.egg-info}/PKG-INFO +1 -1
- {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.3}/wechatbridge_cli.egg-info/SOURCES.txt +1 -0
- {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.3}/LICENSE +0 -0
- {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.3}/README.md +0 -0
- {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.3}/pyproject.toml +0 -0
- {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.3}/setup.cfg +0 -0
- {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.3}/wechatbridge/__main__.py +0 -0
- {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.3}/wechatbridge/config.py +0 -0
- {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.3}/wechatbridge/update_check.py +0 -0
- {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.3}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
- {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.3}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
- {wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.3}/wechatbridge_cli.egg-info/requires.txt +0 -0
- {wechatbridge_cli-1.3.1 → 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"
|
|
@@ -23,6 +23,9 @@ from .runner_common import (
|
|
|
23
23
|
|
|
24
24
|
logger = logging.getLogger("agy_runner")
|
|
25
25
|
|
|
26
|
+
# execve 单参数上限(Linux MAX_ARG_STRLEN = 128KB),留安全余量
|
|
27
|
+
_MAX_ARG_BYTES = 120 * 1024
|
|
28
|
+
|
|
26
29
|
|
|
27
30
|
def extract_artifacts(text: str) -> list[tuple[str, str]]:
|
|
28
31
|
"""Extract (name, absolute_path) tuples from markdown file:/// links.
|
|
@@ -109,6 +112,14 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
|
|
|
109
112
|
if timeout is None:
|
|
110
113
|
timeout = config.agy_timeout
|
|
111
114
|
|
|
115
|
+
# argv 单参数上限约 128KB(MAX_ARG_STRLEN),超长 prompt 直接拒绝,避免 E2BIG
|
|
116
|
+
if len(prompt.encode("utf-8", errors="replace")) > _MAX_ARG_BYTES:
|
|
117
|
+
logger.warning("Prompt too large for argv from user %s", user_id)
|
|
118
|
+
return format_error(
|
|
119
|
+
"消息过长",
|
|
120
|
+
f"单条消息超过 {_MAX_ARG_BYTES // 1024}KB 无法传给 CLI,请精简或分段发送。",
|
|
121
|
+
), []
|
|
122
|
+
|
|
112
123
|
t0 = time.time()
|
|
113
124
|
session_dir = ensure_user_gemini(user_id)
|
|
114
125
|
|
|
@@ -228,10 +239,25 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
|
|
|
228
239
|
env=env,
|
|
229
240
|
preexec_fn=os.setsid if hasattr(os, "setsid") else None,
|
|
230
241
|
)
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
242
|
+
try:
|
|
243
|
+
r_stdout, r_stderr = await asyncio.wait_for(
|
|
244
|
+
retry_process.communicate(),
|
|
245
|
+
timeout=float(timeout),
|
|
246
|
+
)
|
|
247
|
+
except asyncio.TimeoutError:
|
|
248
|
+
# 重试进程也必须回收,否则超时后成为孤儿进程
|
|
249
|
+
logger.warning(
|
|
250
|
+
"agy retry timed out after %ss for user %s, terminating retry process",
|
|
251
|
+
timeout, user_id,
|
|
252
|
+
)
|
|
253
|
+
await terminate_process(retry_process, graceful=True)
|
|
254
|
+
return format_error(
|
|
255
|
+
"级联超时",
|
|
256
|
+
"模型 API 级联推理超时,自动重试仍超时。请稍后重试或简化指令。",
|
|
257
|
+
), []
|
|
258
|
+
except (asyncio.CancelledError, Exception):
|
|
259
|
+
await terminate_process(retry_process, graceful=False)
|
|
260
|
+
raise
|
|
235
261
|
r_stdout_text = r_stdout.decode("utf-8", errors="replace").strip()
|
|
236
262
|
r_stderr_text = r_stderr.decode("utf-8", errors="replace").strip()
|
|
237
263
|
# Any useful stdout after retry counts as recovered (agy may exit non-zero)
|
|
@@ -271,10 +297,15 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
|
|
|
271
297
|
await terminate_process(process, graceful=True)
|
|
272
298
|
return format_error("处理超时", f"超过 {timeout} 秒未完成,已终止本次任务。"), []
|
|
273
299
|
|
|
300
|
+
except asyncio.CancelledError:
|
|
301
|
+
# 任务被取消(如重登录前排空):必须杀掉子进程再传递取消
|
|
302
|
+
await terminate_process(process, graceful=False)
|
|
303
|
+
raise
|
|
304
|
+
|
|
274
305
|
except Exception as e:
|
|
275
306
|
logger.exception("Unexpected error running agy: %s", e)
|
|
276
307
|
await terminate_process(process, graceful=False)
|
|
277
|
-
return format_error("执行出错",
|
|
308
|
+
return format_error("执行出错", "内部错误,详情见服务端日志。"), []
|
|
278
309
|
|
|
279
310
|
|
|
280
311
|
# ---------------------------------------------------------------------------
|
|
@@ -290,6 +321,7 @@ async def _run_agy_subcommand(subcmd_args: list, user_id: str) -> str:
|
|
|
290
321
|
"""
|
|
291
322
|
session_dir = ensure_user_gemini(user_id)
|
|
292
323
|
cmd = [config.agy_binary_path] + subcmd_args
|
|
324
|
+
process = None
|
|
293
325
|
try:
|
|
294
326
|
env = sanitize_env(session_dir)
|
|
295
327
|
process = await asyncio.create_subprocess_exec(
|
|
@@ -320,10 +352,16 @@ async def _run_agy_subcommand(subcmd_args: list, user_id: str) -> str:
|
|
|
320
352
|
return clean_output(stdout_text) or EMPTY_REPLY
|
|
321
353
|
|
|
322
354
|
except asyncio.TimeoutError:
|
|
355
|
+
# 超时必须回收子进程,否则挂死的子命令成为孤儿进程
|
|
356
|
+
await terminate_process(process, graceful=True)
|
|
323
357
|
return format_error("指令超时", "子命令 30 秒内未完成。")
|
|
358
|
+
except asyncio.CancelledError:
|
|
359
|
+
await terminate_process(process, graceful=False)
|
|
360
|
+
raise
|
|
324
361
|
except Exception as e:
|
|
325
362
|
logger.exception("Subcommand error: %s", e)
|
|
326
|
-
|
|
363
|
+
await terminate_process(process, graceful=False)
|
|
364
|
+
return format_error("执行出错", "内部错误,详情见服务端日志。")
|
|
327
365
|
|
|
328
366
|
|
|
329
367
|
def _cmd_help() -> str:
|
|
@@ -612,19 +650,7 @@ async def handle_slash_command(text: str, user_id: str) -> str | None:
|
|
|
612
650
|
"> 用 codegraph 的 search 工具搜 ctxmode"
|
|
613
651
|
)
|
|
614
652
|
|
|
615
|
-
|
|
616
|
-
if not config.enable_subagent:
|
|
617
|
-
return "ℹ️ **该功能已禁用** ℹ️"
|
|
618
|
-
if not args:
|
|
619
|
-
return "❌ **缺少参数** ❌\n\n`/agent <名称> <任务>`"
|
|
620
|
-
# Construct prompt and run through agy
|
|
621
|
-
agent_parts = args.split(maxsplit=1)
|
|
622
|
-
agent_name = agent_parts[0]
|
|
623
|
-
agent_task = agent_parts[1] if len(agent_parts) > 1 else ""
|
|
624
|
-
crafted = f"请用 invoke_subagent 调用 agent {agent_name} 执行任务:{agent_task}"
|
|
625
|
-
logger.info("Agent subcmd: user=%s agent=%s task=%.100s", user_id, agent_name, agent_task)
|
|
626
|
-
result_text, _ = await run_agy(crafted, user_id)
|
|
627
|
-
return result_text
|
|
653
|
+
# /agent 已上移到 main.py 统一处理(必须经过危险确认门,不能再绕过)
|
|
628
654
|
|
|
629
655
|
# --- D class: passthrough to agy (return None so caller runs run_agy) ---
|
|
630
656
|
return None
|
|
@@ -23,6 +23,9 @@ from .runner_common import (
|
|
|
23
23
|
|
|
24
24
|
logger = logging.getLogger("grok_runner")
|
|
25
25
|
|
|
26
|
+
# execve 单参数上限(Linux MAX_ARG_STRLEN = 128KB),留安全余量
|
|
27
|
+
_MAX_ARG_BYTES = 120 * 1024
|
|
28
|
+
|
|
26
29
|
|
|
27
30
|
# ---------------------------------------------------------------------------
|
|
28
31
|
# Per-user .grok directory setup
|
|
@@ -250,13 +253,16 @@ def _build_grok_command(prompt: str, prefs: dict, first: bool, persona_content:
|
|
|
250
253
|
# Artifact extraction from chat_history.jsonl
|
|
251
254
|
# ---------------------------------------------------------------------------
|
|
252
255
|
|
|
253
|
-
def _extract_grok_artifacts(session_dir: str, session_id: str) -> list:
|
|
256
|
+
def _extract_grok_artifacts(session_dir: str, session_id: str, since: float = 0.0) -> list:
|
|
254
257
|
"""Extract (name, abs_path) tuples from grok session chat_history.jsonl.
|
|
255
258
|
|
|
256
259
|
grok stores sessions under $HOME/.grok/sessions/<url-encoded-cwd>/<session-id>/.
|
|
257
260
|
The chat_history.jsonl contains structured tool_calls with file_path arguments
|
|
258
261
|
from write/edit operations.
|
|
259
262
|
|
|
263
|
+
``since``: 只收录 mtime >= since 的文件——chat_history.jsonl 跨轮累积,
|
|
264
|
+
不过滤的话 --continue 会话每轮都会把历史文件重发一遍。
|
|
265
|
+
|
|
260
266
|
Falls back to empty list on any error (never blocks text reply).
|
|
261
267
|
"""
|
|
262
268
|
grok_sessions = os.path.join(session_dir, ".grok", "sessions")
|
|
@@ -292,6 +298,12 @@ def _extract_grok_artifacts(session_dir: str, session_id: str) -> list:
|
|
|
292
298
|
if name in ("write", "edit", "str_replace") and isinstance(args, dict):
|
|
293
299
|
fp = args.get("file_path", "")
|
|
294
300
|
if fp and os.path.isabs(fp):
|
|
301
|
+
# 只收录本轮运行期间新写/修改的文件
|
|
302
|
+
try:
|
|
303
|
+
if since and os.path.getmtime(fp) < since - 2.0:
|
|
304
|
+
continue
|
|
305
|
+
except OSError:
|
|
306
|
+
continue # 文件已不存在,无需回传
|
|
295
307
|
art_name = os.path.basename(fp)
|
|
296
308
|
key = (art_name, fp)
|
|
297
309
|
if key not in seen:
|
|
@@ -305,7 +317,7 @@ def _extract_grok_artifacts(session_dir: str, session_id: str) -> list:
|
|
|
305
317
|
return artifacts
|
|
306
318
|
|
|
307
319
|
|
|
308
|
-
def _parse_grok_output(stdout_text: str, session_dir: str) -> tuple:
|
|
320
|
+
def _parse_grok_output(stdout_text: str, session_dir: str, since: float = 0.0) -> tuple:
|
|
309
321
|
"""Parse grok JSON output into (display_text, artifacts).
|
|
310
322
|
|
|
311
323
|
Handles both success JSON ({text, sessionId, ...}) and error JSON
|
|
@@ -330,7 +342,7 @@ def _parse_grok_output(stdout_text: str, session_dir: str) -> tuple:
|
|
|
330
342
|
|
|
331
343
|
artifacts = []
|
|
332
344
|
if session_id:
|
|
333
|
-
artifacts = _extract_grok_artifacts(session_dir, session_id)
|
|
345
|
+
artifacts = _extract_grok_artifacts(session_dir, session_id, since=since)
|
|
334
346
|
|
|
335
347
|
# Strip file:/// links from display (in case grok emits them)
|
|
336
348
|
display = re.sub(
|
|
@@ -355,6 +367,14 @@ async def run_grok(prompt: str, user_id: str, timeout: int = None) -> tuple:
|
|
|
355
367
|
if timeout is None:
|
|
356
368
|
timeout = config.agy_timeout
|
|
357
369
|
|
|
370
|
+
# argv 单参数上限约 128KB(MAX_ARG_STRLEN),超长 prompt 直接拒绝,避免 E2BIG
|
|
371
|
+
if len(prompt.encode("utf-8", errors="replace")) > _MAX_ARG_BYTES:
|
|
372
|
+
logger.warning("Prompt too large for argv from user %s", user_id)
|
|
373
|
+
return format_error(
|
|
374
|
+
"消息过长",
|
|
375
|
+
f"单条消息超过 {_MAX_ARG_BYTES // 1024}KB 无法传给 CLI,请精简或分段发送。",
|
|
376
|
+
), []
|
|
377
|
+
|
|
358
378
|
t0 = time.time()
|
|
359
379
|
session_dir = ensure_user_grok(user_id)
|
|
360
380
|
|
|
@@ -398,22 +418,22 @@ async def run_grok(prompt: str, user_id: str, timeout: int = None) -> tuple:
|
|
|
398
418
|
stdout_text = stdout_bytes.decode("utf-8", errors="replace").strip()
|
|
399
419
|
stderr_text = stderr_bytes.decode("utf-8", errors="replace").strip()
|
|
400
420
|
|
|
401
|
-
display, artifacts = _parse_grok_output(stdout_text, session_dir)
|
|
421
|
+
display, artifacts = _parse_grok_output(stdout_text, session_dir, since=t0)
|
|
402
422
|
|
|
403
|
-
# Failure detection:
|
|
423
|
+
# Failure detection: 非零退出一律视为失败(与 agy 行为对齐),
|
|
424
|
+
# 零退出但 stdout 是结构化错误(已格式化为 ❌ 前缀)也算失败
|
|
404
425
|
failed = process.returncode != 0 or (
|
|
405
426
|
isinstance(display, str) and display.startswith("❌")
|
|
406
427
|
)
|
|
407
|
-
if process.returncode != 0
|
|
428
|
+
if process.returncode != 0:
|
|
408
429
|
logger.warning(
|
|
409
430
|
"grok exited with code %s for user %s: %.200s",
|
|
410
431
|
process.returncode, user_id, stderr_text,
|
|
411
432
|
)
|
|
412
|
-
display = format_cli_error(
|
|
413
|
-
stderr_text or "grok 进程异常退出", backend="grok"
|
|
414
|
-
)
|
|
415
433
|
artifacts = []
|
|
416
|
-
|
|
434
|
+
if not (isinstance(display, str) and display.startswith("❌")):
|
|
435
|
+
raw_err = stderr_text or ("" if display == EMPTY_REPLY else display) or "grok 进程异常退出"
|
|
436
|
+
display = format_cli_error(raw_err, backend="grok")
|
|
417
437
|
|
|
418
438
|
# Only mark session initialized on a real successful reply
|
|
419
439
|
if first and not failed:
|
|
@@ -431,10 +451,15 @@ async def run_grok(prompt: str, user_id: str, timeout: int = None) -> tuple:
|
|
|
431
451
|
await terminate_process(process, graceful=True)
|
|
432
452
|
return format_error("处理超时", f"超过 {timeout} 秒未完成,已终止本次任务。"), []
|
|
433
453
|
|
|
454
|
+
except asyncio.CancelledError:
|
|
455
|
+
# 任务被取消(如重登录前排空):必须杀掉子进程再传递取消
|
|
456
|
+
await terminate_process(process, graceful=False)
|
|
457
|
+
raise
|
|
458
|
+
|
|
434
459
|
except Exception as e:
|
|
435
460
|
logger.exception("Unexpected error running grok: %s", e)
|
|
436
461
|
await terminate_process(process, graceful=False)
|
|
437
|
-
return format_error("执行出错",
|
|
462
|
+
return format_error("执行出错", "内部错误,详情见服务端日志。"), []
|
|
438
463
|
|
|
439
464
|
|
|
440
465
|
async def _run_grok_subcommand(subcmd_args: list, user_id: str) -> str:
|
|
@@ -445,6 +470,7 @@ async def _run_grok_subcommand(subcmd_args: list, user_id: str) -> str:
|
|
|
445
470
|
"""
|
|
446
471
|
session_dir = ensure_user_grok(user_id)
|
|
447
472
|
cmd = [config.grok_binary_path] + subcmd_args
|
|
473
|
+
process = None
|
|
448
474
|
try:
|
|
449
475
|
env = sanitize_env(session_dir)
|
|
450
476
|
process = await asyncio.create_subprocess_exec(
|
|
@@ -476,10 +502,16 @@ async def _run_grok_subcommand(subcmd_args: list, user_id: str) -> str:
|
|
|
476
502
|
return clean_output(stdout_text) or EMPTY_REPLY
|
|
477
503
|
|
|
478
504
|
except asyncio.TimeoutError:
|
|
505
|
+
# 超时必须回收子进程,否则挂死的子命令成为孤儿进程
|
|
506
|
+
await terminate_process(process, graceful=True)
|
|
479
507
|
return format_error("指令超时", "子命令 30 秒内未完成。")
|
|
508
|
+
except asyncio.CancelledError:
|
|
509
|
+
await terminate_process(process, graceful=False)
|
|
510
|
+
raise
|
|
480
511
|
except Exception as e:
|
|
481
512
|
logger.exception("Subcommand error: %s", e)
|
|
482
|
-
|
|
513
|
+
await terminate_process(process, graceful=False)
|
|
514
|
+
return format_error("执行出错", "内部错误,详情见服务端日志。")
|
|
483
515
|
|
|
484
516
|
|
|
485
517
|
# ---------------------------------------------------------------------------
|
|
@@ -665,18 +697,7 @@ async def handle_grok_slash_command(text: str, user_id: str) -> str | None:
|
|
|
665
697
|
"> 用 codegraph 的 search 工具搜 ctxmode"
|
|
666
698
|
)
|
|
667
699
|
|
|
668
|
-
|
|
669
|
-
if not config.enable_subagent:
|
|
670
|
-
return "ℹ️ **该功能已禁用** ℹ️"
|
|
671
|
-
if not args:
|
|
672
|
-
return "❌ **缺少参数** ❌\n\n`/agent <名称> <任务>`"
|
|
673
|
-
agent_parts = args.split(maxsplit=1)
|
|
674
|
-
agent_name = agent_parts[0]
|
|
675
|
-
agent_task = agent_parts[1] if len(agent_parts) > 1 else ""
|
|
676
|
-
crafted = f"请用 invoke_subagent 调用 agent {agent_name} 执行任务:{agent_task}"
|
|
677
|
-
logger.info("Agent subcmd: user=%s agent=%s task=%.100s", user_id, agent_name, agent_task)
|
|
678
|
-
result_text, _ = await run_grok(crafted, user_id)
|
|
679
|
-
return result_text
|
|
700
|
+
# /agent 已上移到 main.py 统一处理(必须经过危险确认门,不能再绕过)
|
|
680
701
|
|
|
681
702
|
# --- D class: passthrough to grok (return None so caller runs run_grok) ---
|
|
682
703
|
return None
|
|
@@ -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:
|
|
@@ -209,9 +225,12 @@ class ILinkState:
|
|
|
209
225
|
os.chmod(parent, 0o700)
|
|
210
226
|
except OSError:
|
|
211
227
|
pass
|
|
212
|
-
|
|
228
|
+
# tmp + os.replace 原子写,避免崩溃留下半截 state 文件
|
|
229
|
+
tmp_path = self.path + ".tmp"
|
|
230
|
+
with open(tmp_path, "w") as f:
|
|
213
231
|
json.dump(data, f)
|
|
214
|
-
os.chmod(
|
|
232
|
+
os.chmod(tmp_path, 0o600)
|
|
233
|
+
os.replace(tmp_path, self.path)
|
|
215
234
|
except OSError as e:
|
|
216
235
|
logger.error("Failed to save iLink state: %s", e)
|
|
217
236
|
|
|
@@ -270,7 +289,7 @@ class ILinkClient:
|
|
|
270
289
|
return qrcode_str, qrcode_img_content
|
|
271
290
|
|
|
272
291
|
async def poll_qrcode_status(
|
|
273
|
-
self, qrcode_str: str, timeout: int = 180
|
|
292
|
+
self, qrcode_str: str, timeout: int = 180
|
|
274
293
|
) -> Tuple[str, str]:
|
|
275
294
|
"""
|
|
276
295
|
Poll QR code status until confirmed or timeout.
|
|
@@ -369,11 +388,12 @@ class ILinkClient:
|
|
|
369
388
|
logger.warning("HTTP error in get_updates: %s", e)
|
|
370
389
|
return [], buf
|
|
371
390
|
except httpx.RequestError as e:
|
|
391
|
+
# 网络层错误上抛,由主循环做指数退避(避免原地满速重试+日志洪泛)
|
|
372
392
|
logger.warning("Network error in get_updates: %s", e)
|
|
373
|
-
|
|
393
|
+
raise
|
|
374
394
|
except Exception as e:
|
|
375
395
|
logger.exception("Unexpected error in get_updates: %s", e)
|
|
376
|
-
|
|
396
|
+
raise
|
|
377
397
|
|
|
378
398
|
# ---- Media upload and sending ----
|
|
379
399
|
|
|
@@ -457,8 +477,8 @@ class ILinkClient:
|
|
|
457
477
|
)
|
|
458
478
|
elapsed = time.time() - t0
|
|
459
479
|
logger.info(
|
|
460
|
-
"CDN upload done: %d bytes in %.3fs x-encrypted-param
|
|
461
|
-
len(ciphertext), elapsed, encrypted_param,
|
|
480
|
+
"CDN upload done: %d bytes in %.3fs x-encrypted-param(len=%d)",
|
|
481
|
+
len(ciphertext), elapsed, len(encrypted_param),
|
|
462
482
|
)
|
|
463
483
|
return encrypted_param
|
|
464
484
|
except httpx.TimeoutException:
|
|
@@ -629,7 +649,7 @@ class ILinkClient:
|
|
|
629
649
|
|
|
630
650
|
ret = data.get("ret", -1)
|
|
631
651
|
message_id = data.get("message_id", "")
|
|
632
|
-
if ret
|
|
652
|
+
if not ilink_delivery_accepted(ret, message_id):
|
|
633
653
|
logger.warning(
|
|
634
654
|
"%s failed ret=%s for %s (attempt %d/%d): %s",
|
|
635
655
|
log_label, ret, to_user_id, attempt, max_attempts, data,
|
|
@@ -640,14 +660,16 @@ class ILinkClient:
|
|
|
640
660
|
continue
|
|
641
661
|
return False
|
|
642
662
|
|
|
643
|
-
if ret != 0
|
|
644
|
-
|
|
645
|
-
|
|
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)",
|
|
646
667
|
log_label, ret, message_id,
|
|
647
668
|
)
|
|
648
669
|
logger.info(
|
|
649
|
-
"%s sent to %s (attempt %d/%d): msg_id=%s",
|
|
650
|
-
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,
|
|
651
673
|
)
|
|
652
674
|
return True
|
|
653
675
|
|
|
@@ -826,13 +848,13 @@ class ILinkClient:
|
|
|
826
848
|
cl = resp.headers.get("content-length")
|
|
827
849
|
if cl is not None:
|
|
828
850
|
try:
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
851
|
+
declared = int(cl)
|
|
852
|
+
except ValueError:
|
|
853
|
+
declared = None # 非法 Content-Length 头忽略,由流式上限兜底
|
|
854
|
+
if declared is not None and declared > max_in:
|
|
855
|
+
raise ValueError(
|
|
856
|
+
f"入站文件过大(Content-Length {cl} > {max_in} 字节)"
|
|
857
|
+
)
|
|
836
858
|
async for piece in resp.aiter_bytes():
|
|
837
859
|
if not piece:
|
|
838
860
|
continue
|
|
@@ -12,6 +12,7 @@ import os
|
|
|
12
12
|
import sys
|
|
13
13
|
import time
|
|
14
14
|
import uuid
|
|
15
|
+
from collections import OrderedDict
|
|
15
16
|
from io import StringIO
|
|
16
17
|
|
|
17
18
|
from . import __version__
|
|
@@ -21,6 +22,7 @@ from .runner_common import (
|
|
|
21
22
|
clean_session_media,
|
|
22
23
|
format_error,
|
|
23
24
|
format_model_label,
|
|
25
|
+
format_oversized_artifact_notice,
|
|
24
26
|
get_session_dir,
|
|
25
27
|
is_dangerous,
|
|
26
28
|
load_prefs,
|
|
@@ -44,6 +46,71 @@ logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
|
44
46
|
pending_confirms: dict = {}
|
|
45
47
|
|
|
46
48
|
|
|
49
|
+
# Slash 处理器内部信号:该指令已完整处理(如 /agent 进入确认流程),调用方直接 return
|
|
50
|
+
_HANDLED = object()
|
|
51
|
+
|
|
52
|
+
# 重登录/关闭前等待在途消息任务的最长时间
|
|
53
|
+
_DRAIN_TIMEOUT_S = 90.0
|
|
54
|
+
|
|
55
|
+
# 后台任务强引用集合(事件循环只持弱引用,防止任务被 GC 提前回收)
|
|
56
|
+
_background_tasks: set = set()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _spawn_bg(coro) -> asyncio.Task:
|
|
60
|
+
"""create_task + 强引用跟踪,任务结束自动移除。"""
|
|
61
|
+
t = asyncio.create_task(coro)
|
|
62
|
+
_background_tasks.add(t)
|
|
63
|
+
t.add_done_callback(_background_tasks.discard)
|
|
64
|
+
return t
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# 已见消息 ID(LRU,上限 1000)——服务端重投/重启重放时跳过重复处理
|
|
68
|
+
_seen_msg_ids: "OrderedDict[str, None]" = OrderedDict()
|
|
69
|
+
_SEEN_MSG_IDS_CAP = 1000
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# 去重键字段优先级,对齐官方 WeixinMessage schema(Tencent/openclaw-weixin types.ts):
|
|
73
|
+
# message_id(数值) > client_id > item_list[0].msg_id > seq
|
|
74
|
+
def _msg_dedup_key(msg: dict) -> str | None:
|
|
75
|
+
"""从入站消息提取去重键;服务端不下发任何 id 字段时返回 None(无法安全去重)。"""
|
|
76
|
+
for field in ("message_id", "client_id"):
|
|
77
|
+
v = msg.get(field)
|
|
78
|
+
if v:
|
|
79
|
+
return f"{field}:{v}"
|
|
80
|
+
items = msg.get("item_list")
|
|
81
|
+
if isinstance(items, list) and items and isinstance(items[0], dict):
|
|
82
|
+
v = items[0].get("msg_id")
|
|
83
|
+
if v:
|
|
84
|
+
return f"item_msg_id:{v}"
|
|
85
|
+
v = msg.get("seq")
|
|
86
|
+
if v:
|
|
87
|
+
return f"seq:{v}"
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
_dedup_field_announced = False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _is_duplicate_msg(msg: dict) -> bool:
|
|
95
|
+
global _dedup_field_announced
|
|
96
|
+
key = _msg_dedup_key(msg)
|
|
97
|
+
if not _dedup_field_announced:
|
|
98
|
+
_dedup_field_announced = True
|
|
99
|
+
if key:
|
|
100
|
+
logger.info("消息去重已启用,使用字段: %s", key.split(":", 1)[0])
|
|
101
|
+
else:
|
|
102
|
+
logger.info("服务端未下发消息 id 字段,去重停用(依赖服务端游标)")
|
|
103
|
+
if key is None:
|
|
104
|
+
return False
|
|
105
|
+
if key in _seen_msg_ids:
|
|
106
|
+
return True
|
|
107
|
+
_seen_msg_ids[key] = None
|
|
108
|
+
_seen_msg_ids.move_to_end(key)
|
|
109
|
+
while len(_seen_msg_ids) > _SEEN_MSG_IDS_CAP:
|
|
110
|
+
_seen_msg_ids.popitem(last=False)
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
|
|
47
114
|
# ---------------------------------------------------------------------------
|
|
48
115
|
# Backend dispatcher — routes to agy or grok based on per-user preference
|
|
49
116
|
# ---------------------------------------------------------------------------
|
|
@@ -65,10 +132,16 @@ async def _run_llm(prompt: str, user_id: str) -> tuple[str, list]:
|
|
|
65
132
|
return await run_agy(prompt, user_id)
|
|
66
133
|
|
|
67
134
|
|
|
68
|
-
async def _handle_slash(text: str, user_id: str
|
|
135
|
+
async def _handle_slash(client: ILinkClient, text: str, user_id: str, context_token: str):
|
|
69
136
|
"""Dispatch slash command to the active backend's handler.
|
|
70
137
|
|
|
71
|
-
/backend
|
|
138
|
+
/backend 和 /agent 是元指令,在这里处理,不下发给后端。
|
|
139
|
+
|
|
140
|
+
返回值约定:
|
|
141
|
+
str — 直接作为回复文本
|
|
142
|
+
tuple — (reply, artifacts),经确认门后的执行结果
|
|
143
|
+
None — D 类透传,调用方应把原文交给 gate_and_run
|
|
144
|
+
_HANDLED — 已完整处理(如 /agent 进入确认流程),调用方直接 return
|
|
72
145
|
"""
|
|
73
146
|
parts = text.strip().split(maxsplit=1)
|
|
74
147
|
cmd = parts[0].lower() if parts else ""
|
|
@@ -78,6 +151,10 @@ async def _handle_slash(text: str, user_id: str) -> str | None:
|
|
|
78
151
|
if cmd == "/backend":
|
|
79
152
|
return _cmd_backend(args, user_id)
|
|
80
153
|
|
|
154
|
+
# /agent 也在这里处理:统一走 gate_and_run 确认门,不能绕开 is_dangerous
|
|
155
|
+
if cmd == "/agent":
|
|
156
|
+
return await _cmd_agent(client, args, user_id, context_token)
|
|
157
|
+
|
|
81
158
|
if cmd == "/version":
|
|
82
159
|
return (
|
|
83
160
|
f"📦 **版本信息** 📦\n\n当前版本: `{__version__}`\n"
|
|
@@ -134,6 +211,26 @@ def _cmd_backend(args: str, user_id: str) -> str:
|
|
|
134
211
|
)
|
|
135
212
|
|
|
136
213
|
|
|
214
|
+
async def _cmd_agent(client: ILinkClient, args: str, user_id: str, context_token: str):
|
|
215
|
+
"""Handle /agent <名称> <任务> — 调用子代理执行任务。
|
|
216
|
+
|
|
217
|
+
必须经过 gate_and_run 的危险确认门(历史上后端各自实现时绕过了该检查)。
|
|
218
|
+
"""
|
|
219
|
+
if not config.enable_subagent:
|
|
220
|
+
return "ℹ️ **该功能已禁用** ℹ️"
|
|
221
|
+
if not args.strip():
|
|
222
|
+
return "❌ **缺少参数** ❌\n\n`/agent <名称> <任务>`"
|
|
223
|
+
agent_parts = args.split(maxsplit=1)
|
|
224
|
+
agent_name = agent_parts[0]
|
|
225
|
+
agent_task = agent_parts[1] if len(agent_parts) > 1 else ""
|
|
226
|
+
crafted = f"请用 invoke_subagent 调用 agent {agent_name} 执行任务:{agent_task}"
|
|
227
|
+
logger.info("Agent subcmd: user=%s agent=%s task=%.100s", user_id, agent_name, agent_task)
|
|
228
|
+
result = await gate_and_run(client, user_id, context_token, crafted)
|
|
229
|
+
if result is None:
|
|
230
|
+
return _HANDLED # 已进入危险确认流程
|
|
231
|
+
return result # (reply, artifacts)
|
|
232
|
+
|
|
233
|
+
|
|
137
234
|
# ---------------------------------------------------------------------------
|
|
138
235
|
# Image file extension detection
|
|
139
236
|
# ---------------------------------------------------------------------------
|
|
@@ -217,7 +314,6 @@ async def login_flow(client: ILinkClient) -> bool:
|
|
|
217
314
|
bot_token, baseurl = await client.poll_qrcode_status(
|
|
218
315
|
qrcode_str,
|
|
219
316
|
timeout=config.qrcode_poll_timeout,
|
|
220
|
-
interval=config.qrcode_poll_interval,
|
|
221
317
|
)
|
|
222
318
|
client.state.bot_token = bot_token
|
|
223
319
|
client.state.baseurl = baseurl
|
|
@@ -285,9 +381,14 @@ async def send_artifacts_back(client, from_user, context_token, artifacts) -> No
|
|
|
285
381
|
file_size = os.path.getsize(art_path)
|
|
286
382
|
if file_size > config.max_outbound_file_bytes:
|
|
287
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
|
+
)
|
|
288
389
|
await client.send_message(
|
|
289
390
|
to_user_id=from_user,
|
|
290
|
-
text=
|
|
391
|
+
text=format_oversized_artifact_notice(art_name, size_mb),
|
|
291
392
|
context_token=context_token,
|
|
292
393
|
baseurl=client.state.baseurl,
|
|
293
394
|
bot_token=client.state.bot_token,
|
|
@@ -382,14 +483,20 @@ async def process_message(client: ILinkClient, msg: dict) -> None:
|
|
|
382
483
|
await maybe_notify_admin(client, from_user, context_token)
|
|
383
484
|
|
|
384
485
|
# ---- Pending dangerous prompt confirmation ----
|
|
486
|
+
# 确认匹配用文本或语音转写(语音消息也可以回确认口令)
|
|
487
|
+
reply_text = text.strip() or voice_text.strip()
|
|
488
|
+
cancelled_notice = ""
|
|
385
489
|
pending = pending_confirms.get(from_user)
|
|
386
490
|
if pending:
|
|
387
491
|
expired = time.time() >= pending["expire_at"]
|
|
388
|
-
if not expired and
|
|
492
|
+
if not expired and reply_text.lower() == config.confirm_token.lower():
|
|
389
493
|
# User confirmed → run pending prompt, send reply, return
|
|
390
494
|
logger.info("[AUDIT] user=%s confirmed dangerous prompt", from_user)
|
|
391
|
-
|
|
495
|
+
# 先删再执行:执行异常也不能让陈旧 pending 被后续消息误触发
|
|
392
496
|
del pending_confirms[from_user]
|
|
497
|
+
if image_media or file_media:
|
|
498
|
+
logger.info("确认回复携带媒体,媒体部分被忽略 from=%s", from_user)
|
|
499
|
+
reply, artifacts = await _run_llm(pending["prompt"], from_user)
|
|
393
500
|
# Send reply
|
|
394
501
|
success = await client.send_message(
|
|
395
502
|
to_user_id=from_user,
|
|
@@ -409,6 +516,10 @@ async def process_message(client: ILinkClient, msg: dict) -> None:
|
|
|
409
516
|
if expired:
|
|
410
517
|
# Expired: don't reply, continue normal flow
|
|
411
518
|
logger.info("[AUDIT] user=%s pending expired, continue normal flow", from_user)
|
|
519
|
+
elif image_media or file_media:
|
|
520
|
+
# 用户发来新媒体内容:取消待确认并继续处理本条消息(不静默丢弃)
|
|
521
|
+
logger.info("[AUDIT] user=%s cancelled pending by sending new media", from_user)
|
|
522
|
+
cancelled_notice = "🚫 已取消待确认的危险操作。\n\n"
|
|
412
523
|
else:
|
|
413
524
|
# User explicitly cancelled: reply cancelled, return
|
|
414
525
|
logger.info("[AUDIT] user=%s cancelled dangerous prompt", from_user)
|
|
@@ -462,7 +573,9 @@ async def process_message(client: ILinkClient, msg: dict) -> None:
|
|
|
462
573
|
|
|
463
574
|
except Exception as e:
|
|
464
575
|
logger.exception("图片下载/解密失败: %s", e)
|
|
465
|
-
|
|
576
|
+
# ValueError 是自定义的中文可读原因;其他异常(httpx 等)含内部 URL,不外发
|
|
577
|
+
detail = str(e) if isinstance(e, ValueError) else "请重新发送图片。"
|
|
578
|
+
reply = format_error("图片下载或解密失败", detail)
|
|
466
579
|
|
|
467
580
|
# ---- Case 1.5: Message contains a file (non-image) ----
|
|
468
581
|
elif file_media:
|
|
@@ -500,7 +613,9 @@ async def process_message(client: ILinkClient, msg: dict) -> None:
|
|
|
500
613
|
|
|
501
614
|
except Exception as e:
|
|
502
615
|
logger.exception("文件下载/解密失败: %s", e)
|
|
503
|
-
|
|
616
|
+
# ValueError 是自定义的中文可读原因;其他异常(httpx 等)含内部 URL,不外发
|
|
617
|
+
detail = str(e) if isinstance(e, ValueError) else "请重新发送文件。"
|
|
618
|
+
reply = format_error("文件下载或解密失败", detail)
|
|
504
619
|
|
|
505
620
|
# ---- Case 1.6: Voice message (text transcription passthrough) ----
|
|
506
621
|
elif has_voice:
|
|
@@ -526,13 +641,20 @@ async def process_message(client: ILinkClient, msg: dict) -> None:
|
|
|
526
641
|
# Slash command interception
|
|
527
642
|
if text.startswith("/"):
|
|
528
643
|
logger.info("Slash command from=%s: %.200s", from_user, text)
|
|
529
|
-
|
|
530
|
-
if
|
|
644
|
+
handled = await _handle_slash(client, text, from_user, context_token)
|
|
645
|
+
if handled is _HANDLED:
|
|
646
|
+
return
|
|
647
|
+
if handled is None:
|
|
531
648
|
# D class: passthrough — run CLI normally
|
|
532
649
|
result = await gate_and_run(client, from_user, context_token, text)
|
|
533
650
|
if result is None:
|
|
534
651
|
return
|
|
535
652
|
reply, artifacts = result
|
|
653
|
+
elif isinstance(handled, tuple):
|
|
654
|
+
# /agent 等元指令经确认门后的执行结果
|
|
655
|
+
reply, artifacts = handled
|
|
656
|
+
else:
|
|
657
|
+
reply = handled
|
|
536
658
|
else:
|
|
537
659
|
result = await gate_and_run(client, from_user, context_token, text)
|
|
538
660
|
if result is None:
|
|
@@ -542,7 +664,7 @@ async def process_message(client: ILinkClient, msg: dict) -> None:
|
|
|
542
664
|
# ---- Send reply via iLink ----
|
|
543
665
|
success = await client.send_message(
|
|
544
666
|
to_user_id=from_user,
|
|
545
|
-
text=reply,
|
|
667
|
+
text=cancelled_notice + reply,
|
|
546
668
|
context_token=context_token,
|
|
547
669
|
baseurl=client.state.baseurl,
|
|
548
670
|
bot_token=client.state.bot_token,
|
|
@@ -594,7 +716,8 @@ async def periodic_clean_scratch():
|
|
|
594
716
|
while True:
|
|
595
717
|
try:
|
|
596
718
|
await asyncio.sleep(3600)
|
|
597
|
-
|
|
719
|
+
# 同步文件遍历放到线程池,避免阻塞事件循环卡死长轮询心跳
|
|
720
|
+
await asyncio.to_thread(clean_scratch)
|
|
598
721
|
except Exception as e:
|
|
599
722
|
logger.exception("periodic_clean_scratch error: %s", e)
|
|
600
723
|
|
|
@@ -602,17 +725,26 @@ async def periodic_clean_scratch():
|
|
|
602
725
|
async def main_loop() -> None:
|
|
603
726
|
"""Main daemon loop: manages state, QR login, and message receiving."""
|
|
604
727
|
ensure_runtime_dirs()
|
|
605
|
-
|
|
728
|
+
# 同步文件遍历放到线程池,避免阻塞事件循环
|
|
729
|
+
await asyncio.to_thread(clean_scratch)
|
|
606
730
|
if config.update_check_enabled:
|
|
607
|
-
|
|
608
|
-
|
|
731
|
+
_spawn_bg(update_check_loop())
|
|
732
|
+
_spawn_bg(periodic_clean_scratch())
|
|
609
733
|
while True:
|
|
610
734
|
client = ILinkClient()
|
|
735
|
+
# 本轮 client 的在途消息任务(强引用持有,relogin 前排空,避免拿死连接发消息)
|
|
736
|
+
msg_tasks: set = set()
|
|
611
737
|
try:
|
|
612
738
|
state_loaded = client.state.load()
|
|
613
739
|
|
|
614
740
|
if not state_loaded or not client.state.bot_token:
|
|
615
|
-
|
|
741
|
+
try:
|
|
742
|
+
success = await login_flow(client)
|
|
743
|
+
except Exception as e:
|
|
744
|
+
# 网络抖动/服务端异常不应直接杀死 daemon
|
|
745
|
+
logger.exception("登录流程异常,5 秒后重试: %s", e)
|
|
746
|
+
await asyncio.sleep(5)
|
|
747
|
+
continue
|
|
616
748
|
if not success:
|
|
617
749
|
logger.warning("扫码超时,3 秒后重新获取二维码等待扫码")
|
|
618
750
|
await asyncio.sleep(3)
|
|
@@ -624,19 +756,22 @@ async def main_loop() -> None:
|
|
|
624
756
|
|
|
625
757
|
# Inner loop: long-poll for messages
|
|
626
758
|
get_updates_buf = ""
|
|
759
|
+
fail_delay = 0.5 # 网络异常指数退避,封顶 30s
|
|
627
760
|
while True:
|
|
628
761
|
try:
|
|
629
762
|
msgs, new_buf = await client.get_updates(
|
|
630
763
|
get_updates_buf, baseurl, bot_token
|
|
631
764
|
)
|
|
765
|
+
fail_delay = 0.5 # 成功一次即重置退避
|
|
632
766
|
except Exception as e:
|
|
633
767
|
# Token invalidated (401/403) → break for re-login
|
|
634
768
|
logger.exception("长轮询异常: %s", e)
|
|
635
769
|
if not client.state.bot_token:
|
|
636
770
|
logger.warning("Bot token 已失效,准备重新登录")
|
|
637
771
|
break
|
|
638
|
-
# Network hiccup →
|
|
639
|
-
await asyncio.sleep(
|
|
772
|
+
# Network hiccup → 指数退避后重试
|
|
773
|
+
await asyncio.sleep(fail_delay)
|
|
774
|
+
fail_delay = min(fail_delay * 2, 30.0)
|
|
640
775
|
continue
|
|
641
776
|
|
|
642
777
|
# Always update cursor with the server-returned value
|
|
@@ -645,8 +780,14 @@ async def main_loop() -> None:
|
|
|
645
780
|
for msg in msgs:
|
|
646
781
|
msg_type = msg.get("message_type", 0)
|
|
647
782
|
if msg_type == 1: # User message
|
|
783
|
+
if _is_duplicate_msg(msg):
|
|
784
|
+
logger.info("跳过重复投递的消息: %s", _msg_dedup_key(msg))
|
|
785
|
+
continue
|
|
786
|
+
logger.debug("inbound msg keys: %s", sorted(msg.keys()))
|
|
648
787
|
# Non-blocking async task creation: process message in background
|
|
649
|
-
asyncio.create_task(_safe_process_message(client, msg))
|
|
788
|
+
t = asyncio.create_task(_safe_process_message(client, msg))
|
|
789
|
+
msg_tasks.add(t)
|
|
790
|
+
t.add_done_callback(msg_tasks.discard)
|
|
650
791
|
else:
|
|
651
792
|
logger.debug(
|
|
652
793
|
"跳过 message_type=%s", msg_type
|
|
@@ -659,6 +800,19 @@ async def main_loop() -> None:
|
|
|
659
800
|
logger.info("收到退出信号")
|
|
660
801
|
raise
|
|
661
802
|
finally:
|
|
803
|
+
# 先排空/取消在途消息任务,再关连接,避免任务拿死 client 静默失败
|
|
804
|
+
if msg_tasks:
|
|
805
|
+
snapshot = set(msg_tasks)
|
|
806
|
+
logger.info(
|
|
807
|
+
"等待 %d 个在途消息任务完成(最长 %.0fs)...",
|
|
808
|
+
len(snapshot), _DRAIN_TIMEOUT_S,
|
|
809
|
+
)
|
|
810
|
+
_, still_pending = await asyncio.wait(snapshot, timeout=_DRAIN_TIMEOUT_S)
|
|
811
|
+
if still_pending:
|
|
812
|
+
logger.warning("强制取消 %d 个未完成的在途任务", len(still_pending))
|
|
813
|
+
for t in still_pending:
|
|
814
|
+
t.cancel()
|
|
815
|
+
await asyncio.gather(*still_pending, return_exceptions=True)
|
|
662
816
|
await client.close()
|
|
663
817
|
|
|
664
818
|
# Decide whether to re-login or exit
|
|
@@ -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
|
|
|
@@ -22,8 +23,11 @@ logger = logging.getLogger("wechatbridge.runner")
|
|
|
22
23
|
|
|
23
24
|
# ANSI escape code pattern
|
|
24
25
|
ANSI_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")
|
|
25
|
-
# HTML tag pattern
|
|
26
|
-
HTML_TAG_RE = re.compile(
|
|
26
|
+
# HTML tag pattern — 白名单制,避免把代码输出里的 <String>、<T> 等泛型当标签误删
|
|
27
|
+
HTML_TAG_RE = re.compile(
|
|
28
|
+
r"</?(?:b|i|em|strong|code|pre|a|br|div|span|p|u|s|ul|ol|li|table|thead|tbody|tr|td|th|h[1-6]|blockquote|font|center|hr)(?:\s[^<>]*)?/?>",
|
|
29
|
+
re.IGNORECASE,
|
|
30
|
+
)
|
|
27
31
|
|
|
28
32
|
# Sensitive env var prefixes to strip from child process environments
|
|
29
33
|
_SENSITIVE_PREFIXES = (
|
|
@@ -32,7 +36,8 @@ _SENSITIVE_PREFIXES = (
|
|
|
32
36
|
)
|
|
33
37
|
# Segment names that mark a var as secret when they appear as a path part
|
|
34
38
|
_SENSITIVE_SEGMENTS = frozenset({
|
|
35
|
-
"TOKEN", "KEY", "SECRET", "PASSWORD", "PASSWD",
|
|
39
|
+
"TOKEN", "KEY", "SECRET", "PASSWORD", "PASSWD", "PASS", "PWD",
|
|
40
|
+
"AUTH", "CRED", "CREDS", "PRIVATE",
|
|
36
41
|
"CREDENTIAL", "CREDENTIALS", "APIKEY", "API_KEY",
|
|
37
42
|
})
|
|
38
43
|
_SENSITIVE_SUFFIXES = (
|
|
@@ -165,6 +170,19 @@ def format_error(title: str, detail: str = "") -> str:
|
|
|
165
170
|
return f"❌ **{title}** ❌"
|
|
166
171
|
|
|
167
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
|
+
|
|
168
186
|
def format_cli_error(raw_message: str, *, backend: str = "") -> str:
|
|
169
187
|
"""Map CLI stderr/JSON error text into Chinese title + fixed header.
|
|
170
188
|
|
|
@@ -482,8 +500,11 @@ def save_prefs(user_id: str, prefs: dict) -> None:
|
|
|
482
500
|
payload["by_backend"].setdefault(b, _empty_backend_slot())
|
|
483
501
|
# Current backend slot always mirrors active model/effort/mode
|
|
484
502
|
sync_active_to_memory(payload)
|
|
485
|
-
|
|
503
|
+
# tmp + os.replace 原子写,避免崩溃留下半截 prefs.json
|
|
504
|
+
tmp_path = prefs_path + ".tmp"
|
|
505
|
+
with open(tmp_path, "w") as f:
|
|
486
506
|
json.dump(payload, f, ensure_ascii=False, indent=2)
|
|
507
|
+
os.replace(tmp_path, prefs_path)
|
|
487
508
|
# Keep caller's dict in sync with what was written
|
|
488
509
|
prefs.update(payload)
|
|
489
510
|
except OSError as e:
|
|
@@ -609,32 +630,91 @@ _SESSION_HISTORY_REL_DIRS = (
|
|
|
609
630
|
_SQLITE_SIDECAR_TAILS = ("-wal", "-shm", "-journal")
|
|
610
631
|
|
|
611
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
|
+
|
|
612
664
|
def _remove_old_files_under(root: str, cutoff: float) -> int:
|
|
613
665
|
"""Delete files under root older than cutoff; prune empty subdirs.
|
|
614
666
|
|
|
615
667
|
For independent temp files only (images/cache/scratch). Does not remove
|
|
616
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.
|
|
617
673
|
"""
|
|
618
674
|
if not os.path.isdir(root):
|
|
619
675
|
return 0
|
|
620
676
|
removed = 0
|
|
621
677
|
try:
|
|
622
|
-
|
|
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
|
+
)
|
|
623
691
|
for fn in filenames:
|
|
624
692
|
path = os.path.join(dirpath, fn)
|
|
625
693
|
try:
|
|
626
|
-
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):
|
|
627
702
|
continue
|
|
628
|
-
if os.
|
|
703
|
+
if os.lstat(path).st_mtime < cutoff:
|
|
629
704
|
os.remove(path)
|
|
630
705
|
removed += 1
|
|
631
706
|
logger.info("Session cleanup: removed %s", path)
|
|
707
|
+
except FileNotFoundError:
|
|
708
|
+
# raced with another cleaner / process
|
|
709
|
+
continue
|
|
632
710
|
except OSError as e:
|
|
633
711
|
logger.warning("Session cleanup failed %s: %s", path, e)
|
|
634
712
|
if dirpath == root:
|
|
635
713
|
continue
|
|
636
714
|
try:
|
|
637
|
-
|
|
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):
|
|
638
718
|
os.rmdir(dirpath)
|
|
639
719
|
except OSError:
|
|
640
720
|
pass
|
|
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.1 → wechatbridge_cli-1.3.3}/wechatbridge_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{wechatbridge_cli-1.3.1 → wechatbridge_cli-1.3.3}/wechatbridge_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|