wechatbridge-cli 1.4.3__tar.gz → 1.4.4__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 (23) hide show
  1. {wechatbridge_cli-1.4.3/wechatbridge_cli.egg-info → wechatbridge_cli-1.4.4}/PKG-INFO +1 -1
  2. {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.4}/tests/test_hardening.py +249 -1
  3. {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.4}/wechatbridge/__init__.py +1 -1
  4. {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.4}/wechatbridge/agy.py +8 -2
  5. {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.4}/wechatbridge/grok.py +20 -12
  6. {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.4}/wechatbridge/ilink.py +26 -6
  7. {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.4}/wechatbridge/main.py +34 -13
  8. {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.4}/wechatbridge/runner_common.py +24 -0
  9. {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.4/wechatbridge_cli.egg-info}/PKG-INFO +1 -1
  10. {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.4}/LICENSE +0 -0
  11. {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.4}/README.md +0 -0
  12. {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.4}/pyproject.toml +0 -0
  13. {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.4}/setup.cfg +0 -0
  14. {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.4}/tests/test_codex.py +0 -0
  15. {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.4}/wechatbridge/__main__.py +0 -0
  16. {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.4}/wechatbridge/codex.py +0 -0
  17. {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.4}/wechatbridge/config.py +0 -0
  18. {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.4}/wechatbridge/update_check.py +0 -0
  19. {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.4}/wechatbridge_cli.egg-info/SOURCES.txt +0 -0
  20. {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.4}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
  21. {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.4}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
  22. {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.4}/wechatbridge_cli.egg-info/requires.txt +0 -0
  23. {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.4}/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.4.3
3
+ Version: 1.4.4
4
4
  Summary: Bridge WeChat messages to agy, Grok Build, or Codex CLIs — text/image/file/voice in, CLI replies and generated files back.
5
5
  Author: WeChatBridge contributors
6
6
  License: MIT
@@ -399,7 +399,255 @@ class TestSendArtifactsBackCodexAddDirs(unittest.IsolatedAsyncioTestCase):
399
399
  # Exactly the two legitimate artifacts are uploaded; the send API is
400
400
  # never called for any deleted/file/oob/symlink-escape target.
401
401
  self.assertEqual(len(client.send_media.await_args_list), 2)
402
- client.send_message.assert_not_awaited()
402
+
403
+ # Skipped (whitelist) artifacts get a short Chinese notice — no abs path.
404
+ self.assertEqual(client.send_message.await_count, 4)
405
+ for call in client.send_message.await_args_list:
406
+ text = call.kwargs["text"]
407
+ self.assertIn("未能发送", text)
408
+ self.assertNotIn(base, text)
409
+ self.assertNotIn(session_dir, text)
410
+ self.assertNotIn(oob, text)
411
+
412
+
413
+ class TestAgyExtractArtifactsUnquote(unittest.TestCase):
414
+ """agy extract_artifacts must URL-decode percent-encoded paths/names."""
415
+
416
+ def test_space_and_cjk_percent_encoded(self):
417
+ from wechatbridge.agy import extract_artifacts
418
+
419
+ text = (
420
+ "here is [my report.pdf](file:///tmp/scratch/my%20report.pdf) "
421
+ "and [报告.pdf](file:///tmp/scratch/%E6%8A%A5%E5%91%8A.pdf)"
422
+ )
423
+ arts = extract_artifacts(text)
424
+ paths = {p for _, p in arts}
425
+ names = {n for n, _ in arts}
426
+ self.assertIn("/tmp/scratch/my report.pdf", paths)
427
+ self.assertIn("/tmp/scratch/报告.pdf", paths)
428
+ self.assertIn("my report.pdf", names)
429
+ self.assertIn("报告.pdf", names)
430
+ # Encoded forms must not leak into resolved paths
431
+ for p in paths:
432
+ self.assertNotIn("%20", p)
433
+ self.assertNotIn("%E6", p)
434
+
435
+
436
+ class TestSendArtifactsBackAgyAddDirs(unittest.IsolatedAsyncioTestCase):
437
+ """agy must honour validated --add-dir roots at send time (like codex)."""
438
+
439
+ async def test_agy_add_dir_artifact_sent(self):
440
+ from wechatbridge.main import send_artifacts_back
441
+ from wechatbridge.config import config
442
+
443
+ with tempfile.TemporaryDirectory() as base:
444
+ session_dir = os.path.join(base, "session")
445
+ os.makedirs(session_dir)
446
+ scratch = os.path.join(
447
+ session_dir, ".gemini", "antigravity-cli", "scratch"
448
+ )
449
+ os.makedirs(scratch)
450
+
451
+ allowed_extra = os.path.join(base, "allowed_extra")
452
+ os.makedirs(allowed_extra)
453
+ good_dir = os.path.join(allowed_extra, "proj")
454
+ os.makedirs(good_dir)
455
+ good_art = os.path.join(good_dir, "doc.pdf")
456
+ with open(good_art, "w", encoding="utf-8") as f:
457
+ f.write("%PDF-1.4 ok")
458
+
459
+ scratch_art = os.path.join(scratch, "local.txt")
460
+ with open(scratch_art, "w", encoding="utf-8") as f:
461
+ f.write("scratch")
462
+
463
+ # out-of-bounds must still be blocked
464
+ oob = os.path.join(base, "oob")
465
+ os.makedirs(oob)
466
+ oob_art = os.path.join(oob, "secret.txt")
467
+ with open(oob_art, "w", encoding="utf-8") as f:
468
+ f.write("secret")
469
+
470
+ prefs = {"backend": "agy", "add_dirs": [good_dir]}
471
+ with open(
472
+ os.path.join(session_dir, "prefs.json"), "w", encoding="utf-8"
473
+ ) as f:
474
+ json.dump(prefs, f)
475
+
476
+ client = MagicMock()
477
+ client.state.baseurl = "https://example.test"
478
+ client.state.bot_token = "tok"
479
+ client.send_message = AsyncMock(return_value=True)
480
+ client.send_media = AsyncMock(return_value=True)
481
+
482
+ artifacts = [
483
+ ("doc.pdf", good_art),
484
+ ("local.txt", scratch_art),
485
+ ("secret.txt", oob_art),
486
+ ]
487
+
488
+ with mock.patch(
489
+ "wechatbridge.main.get_session_dir", return_value=session_dir
490
+ ), mock.patch(
491
+ "wechatbridge.runner_common.get_session_dir", return_value=session_dir
492
+ ), mock.patch(
493
+ "wechatbridge.main._get_backend", return_value="agy"
494
+ ), mock.patch.object(
495
+ config, "add_dir_roots", [allowed_extra]
496
+ ):
497
+ await send_artifacts_back(
498
+ client, "user-1", "ctx-token", artifacts
499
+ )
500
+
501
+ sent = {c.kwargs["path"] for c in client.send_media.await_args_list}
502
+ self.assertIn(good_art, sent)
503
+ self.assertIn(scratch_art, sent)
504
+ self.assertNotIn(oob_art, sent)
505
+ self.assertEqual(len(client.send_media.await_args_list), 2)
506
+
507
+
508
+ class TestMediaTypeForPath(unittest.TestCase):
509
+ def test_pdf_png_svg(self):
510
+ from wechatbridge.ilink import media_type_for_path, MEDIA_IMAGE, MEDIA_FILE
511
+
512
+ self.assertEqual(media_type_for_path("/tmp/a.pdf"), MEDIA_FILE)
513
+ self.assertEqual(media_type_for_path("/tmp/a.docx"), MEDIA_FILE)
514
+ self.assertEqual(media_type_for_path("/tmp/a.xlsx"), MEDIA_FILE)
515
+ self.assertEqual(media_type_for_path("/tmp/a.txt"), MEDIA_FILE)
516
+ self.assertEqual(media_type_for_path("/tmp/a.zip"), MEDIA_FILE)
517
+ self.assertEqual(media_type_for_path("/tmp/a.png"), MEDIA_IMAGE)
518
+ self.assertEqual(media_type_for_path("/tmp/a.jpg"), MEDIA_IMAGE)
519
+ self.assertEqual(media_type_for_path("/tmp/a.jpeg"), MEDIA_IMAGE)
520
+ self.assertEqual(media_type_for_path("/tmp/a.gif"), MEDIA_IMAGE)
521
+ self.assertEqual(media_type_for_path("/tmp/a.webp"), MEDIA_IMAGE)
522
+ self.assertEqual(media_type_for_path("/tmp/a.svg"), MEDIA_FILE)
523
+ self.assertEqual(media_type_for_path("/tmp/a.heic"), MEDIA_FILE)
524
+ self.assertEqual(media_type_for_path("/tmp/a.bin"), MEDIA_FILE)
525
+
526
+
527
+ class TestArtifactSendFailureNotice(unittest.TestCase):
528
+ def test_helper_no_absolute_path(self):
529
+ from wechatbridge.runner_common import format_artifact_send_failure_notice
530
+
531
+ art_path = "/root/.local/share/wechatbridge/default/sessions/u1/x.pdf"
532
+ for reason in ("skipped", "not_found", "send_failed", "error"):
533
+ text = format_artifact_send_failure_notice("x.pdf", reason)
534
+ self.assertNotIn(art_path, text)
535
+ self.assertNotIn("/root/", text)
536
+ self.assertIn("x.pdf", text)
537
+ self.assertIn("未能发送", text)
538
+
539
+ def test_send_failed_notifies_user(self):
540
+ from wechatbridge.main import send_artifacts_back
541
+
542
+ async def _run():
543
+ with tempfile.TemporaryDirectory() as td:
544
+ scratch = os.path.join(td, ".gemini", "antigravity-cli", "scratch")
545
+ os.makedirs(scratch)
546
+ art = os.path.join(scratch, "report.pdf")
547
+ with open(art, "w", encoding="utf-8") as f:
548
+ f.write("pdf")
549
+
550
+ client = MagicMock()
551
+ client.state.baseurl = "https://example.test"
552
+ client.state.bot_token = "tok"
553
+ client.send_message = AsyncMock(return_value=True)
554
+ client.send_media = AsyncMock(return_value=False) # send fails
555
+
556
+ with mock.patch(
557
+ "wechatbridge.main.get_session_dir", return_value=td
558
+ ), mock.patch(
559
+ "wechatbridge.main._get_backend", return_value="agy"
560
+ ):
561
+ await send_artifacts_back(
562
+ client, "user-1", "ctx-token", [("report.pdf", art)]
563
+ )
564
+
565
+ client.send_media.assert_awaited()
566
+ client.send_message.assert_awaited()
567
+ text = client.send_message.await_args.kwargs["text"]
568
+ self.assertIn("report.pdf", text)
569
+ self.assertIn("未能发送", text)
570
+ self.assertNotIn(art, text)
571
+ self.assertNotIn(td, text)
572
+
573
+ asyncio.run(_run())
574
+
575
+ def test_not_found_notifies_user(self):
576
+ from wechatbridge.main import send_artifacts_back
577
+
578
+ async def _run():
579
+ with tempfile.TemporaryDirectory() as td:
580
+ scratch = os.path.join(td, ".gemini", "antigravity-cli", "scratch")
581
+ os.makedirs(scratch)
582
+ missing = os.path.join(scratch, "gone.pdf")
583
+
584
+ client = MagicMock()
585
+ client.state.baseurl = "https://example.test"
586
+ client.state.bot_token = "tok"
587
+ client.send_message = AsyncMock(return_value=True)
588
+ client.send_media = AsyncMock(return_value=True)
589
+
590
+ with mock.patch(
591
+ "wechatbridge.main.get_session_dir", return_value=td
592
+ ), mock.patch(
593
+ "wechatbridge.main._get_backend", return_value="agy"
594
+ ):
595
+ await send_artifacts_back(
596
+ client, "user-1", "ctx-token", [("gone.pdf", missing)]
597
+ )
598
+
599
+ client.send_media.assert_not_awaited()
600
+ client.send_message.assert_awaited()
601
+ text = client.send_message.await_args.kwargs["text"]
602
+ self.assertIn("gone.pdf", text)
603
+ self.assertIn("未能发送", text)
604
+ self.assertNotIn(missing, text)
605
+ self.assertNotIn(td, text)
606
+
607
+ asyncio.run(_run())
608
+
609
+
610
+ class TestGrokRelativePathArtifacts(unittest.TestCase):
611
+ """grok relative file_path must join session_dir then abspath (like codex)."""
612
+
613
+ def test_relative_path_resolved(self):
614
+ from wechatbridge.grok import _extract_grok_artifacts
615
+ import urllib.parse
616
+
617
+ with tempfile.TemporaryDirectory() as session_dir:
618
+ # Create the relative file under session_dir
619
+ rel_name = "notes/out.txt"
620
+ abs_file = os.path.join(session_dir, "notes", "out.txt")
621
+ os.makedirs(os.path.dirname(abs_file))
622
+ with open(abs_file, "w", encoding="utf-8") as f:
623
+ f.write("hello")
624
+
625
+ # Fake grok chat_history.jsonl layout
626
+ cwd_encoded = urllib.parse.quote(session_dir, safe="")
627
+ session_id = "sess-rel-1"
628
+ hist_dir = os.path.join(
629
+ session_dir, ".grok", "sessions", cwd_encoded, session_id
630
+ )
631
+ os.makedirs(hist_dir)
632
+ hist = os.path.join(hist_dir, "chat_history.jsonl")
633
+ line = {
634
+ "type": "assistant",
635
+ "tool_calls": [
636
+ {
637
+ "name": "write",
638
+ "arguments": json.dumps({"file_path": rel_name}),
639
+ }
640
+ ],
641
+ }
642
+ with open(hist, "w", encoding="utf-8") as f:
643
+ f.write(json.dumps(line) + "\n")
644
+
645
+ arts = _extract_grok_artifacts(session_dir, session_id, since=0.0)
646
+ self.assertEqual(len(arts), 1)
647
+ name, path = arts[0]
648
+ self.assertEqual(name, "out.txt")
649
+ self.assertEqual(path, os.path.abspath(abs_file))
650
+ self.assertTrue(os.path.isabs(path))
403
651
 
404
652
 
405
653
  class TestILinkDeliveryAccepted(unittest.TestCase):
@@ -1,2 +1,2 @@
1
1
  """WeChatBridge — bridge WeChat messages to agy, Grok Build, or Codex CLIs."""
2
- __version__ = "1.4.3"
2
+ __version__ = "1.4.4"
@@ -11,6 +11,7 @@ import shutil
11
11
  import signal
12
12
  import sys
13
13
  import time
14
+ from urllib.parse import unquote
14
15
 
15
16
  from .config import config
16
17
  from .runner_common import (
@@ -32,14 +33,19 @@ def extract_artifacts(text: str) -> list[tuple[str, str]]:
32
33
 
33
34
  Uses regex ``\\[([^\\]]+)\\](file:///([^)]+))`` to find agy-generated
34
35
  artifact references in stdout. Returns deduplicated, order-preserved list.
36
+
37
+ Paths and display names are URL-decoded (``urllib.parse.unquote``) so
38
+ percent-encoded spaces / CJK (e.g. ``my%20report.pdf``, ``%E6%8A%A5%E5%91%8A.pdf``)
39
+ resolve to real filesystem paths.
35
40
  """
36
41
  if not text:
37
42
  return []
38
43
  seen = set()
39
44
  result = []
40
45
  for match in re.finditer(r"\[([^\]]+)\]\(file:///([^)]+)\)", text):
41
- name = match.group(1).split("#")[0]
42
- abs_path = "/" + match.group(2).split("#")[0]
46
+ name = unquote(match.group(1).split("#")[0])
47
+ path_part = unquote(match.group(2).split("#")[0])
48
+ abs_path = path_part if path_part.startswith("/") else "/" + path_part
43
49
  key = (name, abs_path)
44
50
  if key not in seen:
45
51
  seen.add(key)
@@ -324,18 +324,26 @@ def _extract_grok_artifacts(session_dir: str, session_id: str, since: float = 0.
324
324
  continue
325
325
  if name in ("write", "edit", "str_replace") and isinstance(args, dict):
326
326
  fp = args.get("file_path", "")
327
- if fp and os.path.isabs(fp):
328
- # 只收录本轮运行期间新写/修改的文件
329
- try:
330
- if since and os.path.getmtime(fp) < since - 2.0:
331
- continue
332
- except OSError:
333
- continue # 文件已不存在,无需回传
334
- art_name = os.path.basename(fp)
335
- key = (art_name, fp)
336
- if key not in seen:
337
- seen.add(key)
338
- artifacts.append(key)
327
+ if not fp:
328
+ continue
329
+ # Relative paths resolve against session_dir (cwd)
330
+ if not os.path.isabs(fp):
331
+ fp = os.path.join(session_dir, fp)
332
+ try:
333
+ fp = os.path.abspath(fp)
334
+ except (OSError, ValueError):
335
+ continue
336
+ # 只收录本轮运行期间新写/修改的文件
337
+ try:
338
+ if since and os.path.getmtime(fp) < since - 2.0:
339
+ continue
340
+ except OSError:
341
+ continue # 文件已不存在,无需回传
342
+ art_name = os.path.basename(fp)
343
+ key = (art_name, fp)
344
+ if key not in seen:
345
+ seen.add(key)
346
+ artifacts.append(key)
339
347
  except OSError as e:
340
348
  logger.warning("Failed to read chat_history.jsonl: %s", e)
341
349
 
@@ -25,6 +25,30 @@ logger = logging.getLogger("ilink")
25
25
 
26
26
  ILINK_BASE = config.ilink_base_url.rstrip("/")
27
27
 
28
+ # WeChat image_item handles these well; other image/* (svg/heic/…) go as FILE.
29
+ _DISPLAYABLE_IMAGE_MIMES = frozenset({
30
+ "image/jpeg",
31
+ "image/png",
32
+ "image/gif",
33
+ "image/webp",
34
+ "image/bmp",
35
+ })
36
+
37
+ MEDIA_IMAGE = 1
38
+ MEDIA_FILE = 3
39
+
40
+
41
+ def media_type_for_path(path: str) -> int:
42
+ """Return MEDIA_IMAGE or MEDIA_FILE for a local path based on guessed MIME.
43
+
44
+ Only common displayable raster images use IMAGE; everything else
45
+ (pdf/doc/xlsx/txt/zip, svg/heic, unknown) uses FILE so file_name is kept.
46
+ """
47
+ mime = mimetypes.guess_type(path)[0] or "application/octet-stream"
48
+ if mime in _DISPLAYABLE_IMAGE_MIMES:
49
+ return MEDIA_IMAGE
50
+ return MEDIA_FILE
51
+
28
52
 
29
53
  def ilink_delivery_accepted(ret, message_id) -> bool:
30
54
  """Whether an iLink sendmessage JSON body means the message was accepted.
@@ -530,12 +554,8 @@ class ILinkClient:
530
554
  ciphertext = _encrypt_aes_ecb(plaintext, aes_key)
531
555
  filesize = len(ciphertext)
532
556
 
533
- # Determine media_type from mime
534
- mime = mimetypes.guess_type(path)[0] or "application/octet-stream"
535
- if mime.startswith("image/"):
536
- media_type = 1 # MEDIA_IMAGE
537
- else:
538
- media_type = 3 # MEDIA_FILE
557
+ # Determine media_type from mime (only common rasters → IMAGE)
558
+ media_type = media_type_for_path(path)
539
559
 
540
560
  # Get CDN upload URL
541
561
  upload_param, upload_full_url = await self.get_upload_url(
@@ -23,6 +23,7 @@ from .runner_common import (
23
23
  clean_session_media,
24
24
  clear_initialized,
25
25
  classify_upstream_failure,
26
+ format_artifact_send_failure_notice,
26
27
  format_error,
27
28
  format_model_label,
28
29
  format_oversized_artifact_notice,
@@ -646,8 +647,9 @@ async def gate_and_run(
646
647
  async def send_artifacts_back(client, from_user, context_token, artifacts) -> None:
647
648
  """Filter artifacts: only send back those under per-user session dir.
648
649
 
649
- For agy: artifacts under .gemini/antigravity-cli/scratch
650
- For grok: artifacts under session_dir (cwd where grok ran)
650
+ For agy: artifacts under .gemini/antigravity-cli/scratch (plus validated --add-dir)
651
+ For codex: artifacts under session_dir (plus validated --add-dir)
652
+ For grok: artifacts under session_dir (cwd where grok ran; no add-dir gate)
651
653
  """
652
654
  session_dir = get_session_dir(from_user)
653
655
  backend = _get_backend(from_user)
@@ -659,22 +661,34 @@ async def send_artifacts_back(client, from_user, context_token, artifacts) -> No
659
661
  # codex runs with cwd=session_dir; file_change paths may also land in
660
662
  # user-approved --add-dir roots, so allow those too.
661
663
  allowed_root = session_dir
664
+ else:
665
+ # agy writes to .gemini/antigravity-cli/scratch under session_dir
666
+ allowed_root = os.path.join(session_dir, ".gemini", "antigravity-cli", "scratch")
667
+
668
+ # codex and agy both support --add-dir; re-verify stored roots at send time.
669
+ # Only keep roots that currently exist, are real directories, and still
670
+ # resolve inside the configured allowed roots (session dir + config
671
+ # add_dir_roots). Deleted dirs, plain files, out-of-bounds paths and
672
+ # symlink escapes must not become artifact allow roots. Legitimate
673
+ # directories are kept (and still re-checked against each artifact path
674
+ # below). session_dir itself is never relaxed. grok does not use add-dir.
675
+ if backend in ("codex", "agy"):
662
676
  prefs = load_prefs(from_user)
663
- # Second-factor verification of stored --add-dir roots at send time.
664
- # Only keep roots that currently exist, are real directories, and still
665
- # resolve inside the configured allowed roots (session dir + config
666
- # add_dir_roots). Deleted dirs, plain files, out-of-bounds paths and
667
- # symlink escapes must not become artifact allow roots. Legitimate
668
- # directories are kept (and still re-checked against each artifact path
669
- # below). session_dir itself is never relaxed.
670
- add_dirs = []
671
677
  for d in prefs.get("add_dirs", []) or []:
672
678
  ok, resolved = validate_add_dir(d, from_user)
673
679
  if ok:
674
680
  add_dirs.append(resolved)
675
- else:
676
- # agy writes to .gemini/antigravity-cli/scratch under session_dir
677
- allowed_root = os.path.join(session_dir, ".gemini", "antigravity-cli", "scratch")
681
+
682
+ async def _notify_failure(name: str, reason: str) -> None:
683
+ # Never echo server absolute paths to WeChat users
684
+ await client.send_message(
685
+ to_user_id=from_user,
686
+ text=format_artifact_send_failure_notice(name, reason),
687
+ context_token=context_token,
688
+ baseurl=client.state.baseurl,
689
+ bot_token=client.state.bot_token,
690
+ )
691
+
678
692
  for art_name, art_path in artifacts:
679
693
  try:
680
694
  # realpath check blocks symlink escape outside allowed root
@@ -686,9 +700,11 @@ async def send_artifacts_back(client, from_user, context_token, artifacts) -> No
686
700
  break
687
701
  if not ok_root:
688
702
  logger.debug("skip non-scratch artifact: %s", art_path)
703
+ await _notify_failure(art_name, "skipped")
689
704
  continue
690
705
  if not os.path.isfile(os.path.realpath(art_path)):
691
706
  logger.warning("Artifact not found: %s", art_path)
707
+ await _notify_failure(art_name, "not_found")
692
708
  continue
693
709
  art_path = os.path.realpath(art_path)
694
710
  file_size = os.path.getsize(art_path)
@@ -719,8 +735,13 @@ async def send_artifacts_back(client, from_user, context_token, artifacts) -> No
719
735
  logger.info("Artifact sent: %s -> %s", art_name, from_user)
720
736
  else:
721
737
  logger.warning("Failed to send artifact: %s", art_name)
738
+ await _notify_failure(art_name, "send_failed")
722
739
  except Exception as e:
723
740
  logger.exception("Error sending artifact %s: %s", art_name, e)
741
+ try:
742
+ await _notify_failure(art_name, "error")
743
+ except Exception:
744
+ logger.exception("Failed to notify user about artifact error: %s", art_name)
724
745
 
725
746
 
726
747
  # ---------------------------------------------------------------------------
@@ -259,6 +259,30 @@ def format_oversized_artifact_notice(art_name: str, size_mb: float) -> str:
259
259
  )
260
260
 
261
261
 
262
+ # Internal reason codes for format_artifact_send_failure_notice
263
+ _ARTIFACT_FAIL_REASONS = {
264
+ "skipped": "不在允许回传的目录内,已跳过",
265
+ "not_found": "文件不存在或不是普通文件",
266
+ "send_failed": "发送失败,请稍后重试",
267
+ "error": "发送出错,请稍后重试",
268
+ }
269
+
270
+
271
+ def format_artifact_send_failure_notice(art_name: str, reason: str) -> str:
272
+ """User-facing text when an artifact cannot be sent back to WeChat.
273
+
274
+ ``reason`` is an internal code: skipped / not_found / send_failed / error.
275
+ Must never include server absolute paths — only the display name and reason.
276
+ """
277
+ name = (art_name or "file").replace("`", "'").strip() or "file"
278
+ detail = _ARTIFACT_FAIL_REASONS.get(reason) or _ARTIFACT_FAIL_REASONS["error"]
279
+ return (
280
+ f"⚠️ **文件未能发送** ⚠️\n\n"
281
+ f"`{name}`\n"
282
+ f"{detail}"
283
+ )
284
+
285
+
262
286
  def format_cli_error(raw_message: str, *, backend: str = "") -> str:
263
287
  """Map backend stderr/JSON error text into a short Chinese user reply.
264
288
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wechatbridge-cli
3
- Version: 1.4.3
3
+ Version: 1.4.4
4
4
  Summary: Bridge WeChat messages to agy, Grok Build, or Codex CLIs — text/image/file/voice in, CLI replies and generated files back.
5
5
  Author: WeChatBridge contributors
6
6
  License: MIT