wechatbridge-cli 1.4.4__tar.gz → 1.4.5__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.4/wechatbridge_cli.egg-info → wechatbridge_cli-1.4.5}/PKG-INFO +1 -1
  2. {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.5}/tests/test_codex.py +256 -0
  3. {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.5}/wechatbridge/__init__.py +1 -1
  4. {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.5}/wechatbridge/codex.py +113 -2
  5. {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.5/wechatbridge_cli.egg-info}/PKG-INFO +1 -1
  6. {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.5}/LICENSE +0 -0
  7. {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.5}/README.md +0 -0
  8. {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.5}/pyproject.toml +0 -0
  9. {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.5}/setup.cfg +0 -0
  10. {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.5}/tests/test_hardening.py +0 -0
  11. {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.5}/wechatbridge/__main__.py +0 -0
  12. {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.5}/wechatbridge/agy.py +0 -0
  13. {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.5}/wechatbridge/config.py +0 -0
  14. {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.5}/wechatbridge/grok.py +0 -0
  15. {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.5}/wechatbridge/ilink.py +0 -0
  16. {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.5}/wechatbridge/main.py +0 -0
  17. {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.5}/wechatbridge/runner_common.py +0 -0
  18. {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.5}/wechatbridge/update_check.py +0 -0
  19. {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.5}/wechatbridge_cli.egg-info/SOURCES.txt +0 -0
  20. {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.5}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
  21. {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.5}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
  22. {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.5}/wechatbridge_cli.egg-info/requires.txt +0 -0
  23. {wechatbridge_cli-1.4.4 → wechatbridge_cli-1.4.5}/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.4
3
+ Version: 1.4.5
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
@@ -908,6 +908,262 @@ class TestCodexRunFakeCli(unittest.IsolatedAsyncioTestCase):
908
908
  self.assertFalse(os.path.isfile(os.path.join(sd, ".initialized.codex")))
909
909
 
910
910
 
911
+ # --- Fallback scan tests for shell-created artifacts ---
912
+
913
+ def test_shell_file_fallback_collects_new_files(self):
914
+ """ok_shell_files: new file created during execution is collected by fallback."""
915
+ display, artifacts = self._run("hi", "u-shell-new", mode="ok_shell_files")
916
+ self.assertIn("first(", display)
917
+ names = {n for n, _ in artifacts}
918
+ self.assertIn("result.txt", names, "fallback should collect new file")
919
+ self.assertNotIn("out.md", names, "old file should NOT be collected")
920
+ # All artifact paths must be absolute and under session_dir
921
+ for _n, ap in artifacts:
922
+ self.assertTrue(os.path.isabs(ap))
923
+ sd = os.path.join(self.td, _sanitize("u-shell-new"))
924
+ self.assertTrue(ap.startswith(sd))
925
+
926
+ def test_shell_file_fallback_dedup_with_file_change(self):
927
+ """ok_shell_and_file_change: file_change is authoritative, no duplicate."""
928
+ display, artifacts = self._run("hi", "u-shell-dedup", mode="ok_shell_and_file_change")
929
+ names = {n for n, _ in artifacts}
930
+ # file_change collected result.txt; fallback should NOT add a duplicate
931
+ self.assertEqual(names.count("result.txt") if hasattr(names, 'count')
932
+ else len([n for n in artifacts if n[0] == "result.txt"]),
933
+ len([n for n in artifacts if n[0] == "result.txt"]))
934
+ # Exactly one result.txt in artifacts (from file_change, not fallback)
935
+ result_entries = [a for a in artifacts if a[0] == "result.txt"]
936
+ self.assertEqual(len(result_entries), 1, "no duplicate from fallback")
937
+
938
+ def test_shell_file_fallback_old_files_not_collected(self):
939
+ """ok_shell_old_files: files with old mtimes are NOT collected."""
940
+ display, artifacts = self._run("hi", "u-shell-old", mode="ok_shell_old_files")
941
+ self.assertIn("first(", display)
942
+ names = {n for n, _ in artifacts}
943
+ self.assertNotIn("old_result.txt", names, "old file should not be collected")
944
+ self.assertNotIn("old_output.md", names, "old file should not be collected")
945
+
946
+ def test_shell_file_fallback_internal_metadata_excluded(self):
947
+ """ok_shell_internal_metadata: .codex/ and .git/ files are excluded."""
948
+ display, artifacts = self._run("hi", "u-shell-meta", mode="ok_shell_internal_metadata")
949
+ self.assertIn("first(", display)
950
+ names = {n for n, _ in artifacts}
951
+ self.assertIn("real_output.txt", names, "real file should be collected")
952
+ self.assertNotIn("meta.json", names, ".codex/ metadata should be excluded")
953
+ self.assertNotIn("config", names, ".git/ file should be excluded")
954
+ # No path should contain .codex or .git as a directory component
955
+ for _n, ap in artifacts:
956
+ parts = ap.split(os.sep)
957
+ self.assertNotIn(".codex", parts)
958
+ self.assertNotIn(".git", parts)
959
+
960
+ def test_shell_file_fallback_symlink_escape_excluded(self):
961
+ """ok_shell_escape: symlink pointing outside allowed roots is NOT collected."""
962
+ display, artifacts = self._run("hi", "u-shell-escape", mode="ok_shell_escape")
963
+ self.assertIn("first(", display)
964
+ names = {n for n, _ in artifacts}
965
+ self.assertNotIn("escaped.txt", names, "symlink escape should be excluded")
966
+ # Cleanup target
967
+ try:
968
+ os.unlink("/tmp/_codex_escape_test_target.txt")
969
+ except OSError:
970
+ pass
971
+
972
+ def test_fallback_realpath_alias_dedup(self):
973
+ """Symlink alias to a file collected via file_change: same realpath -> no duplicate."""
974
+ import os, sys, json, uuid, datetime
975
+ target = os.path.join(self.td, "_dedup_target.txt")
976
+ with open(target, "w") as f:
977
+ f.write("content")
978
+ link = os.path.join(self.td, "_dedup_alias.txt")
979
+ os.symlink(target, link)
980
+
981
+ # Build a custom shim that: emits file_change for the alias path AND writes the real file.
982
+ fake = os.path.join(self.td, "_fake_dedup.py")
983
+ with open(fake, "w") as f:
984
+ f.write(
985
+ '#!/usr/bin/env python3\n'
986
+ 'import json, os, sys, uuid, datetime\n'
987
+ 'session_dir = os.getcwd()\n'
988
+ 'new_id = str(uuid.uuid4())\n'
989
+ 'now = datetime.datetime.now()\n'
990
+ 'ts = now.strftime("%Y-%m-%dT%H-%M-%S")\n'
991
+ 'rollout_dir = os.path.join(session_dir, ".codex", "sessions",\n'
992
+ ' now.strftime("%Y"), now.strftime("%m"), now.strftime("%d"))\n'
993
+ 'os.makedirs(rollout_dir, exist_ok=True)\n'
994
+ 'with open(os.path.join(rollout_dir, f"rollout-{ts}-{new_id}.jsonl"), "w") as fo:\n'
995
+ ' fo.write(json.dumps({"type": "session_meta", "payload": {"id": new_id}}) + "\\n")\n'
996
+ ' fo.write(json.dumps({"type": "thread.started", "thread_id": new_id}) + "\\n")\n'
997
+ 'lines = [\n'
998
+ ' json.dumps({"type": "thread.started", "thread_id": new_id}),\n'
999
+ ' json.dumps({"type": "turn.started"}),\n'
1000
+ ' json.dumps({"type": "item.completed", "item": {"id": "i1", "type": "agent_message", "text": "done"}}),\n'
1001
+ ' json.dumps({"type": "item.completed", "item": {\n'
1002
+ ' "id": "i2", "type": "file_change", "status": "completed",\n'
1003
+ ' "changes": [{"kind": "add", "path": "_dedup_alias.txt"}]\n'
1004
+ ' }}),\n'
1005
+ ' json.dumps({"type": "turn.completed", "usage": {}}),\n'
1006
+ ']\n'
1007
+ 'sys.stdout.write("\\n".join(lines) + "\\n")\n'
1008
+ 'sys.stdout.flush()\n'
1009
+ 'with open(os.path.join(session_dir, "_dedup_target.txt"), "w") as fo:\n'
1010
+ ' fo.write("content")\n'
1011
+ 'sys.exit(0)\n'
1012
+ )
1013
+ os.chmod(fake, 0o755)
1014
+ shim = os.path.join(self.td, "_shim_dedup.py")
1015
+ with open(shim, "w") as f:
1016
+ f.write(
1017
+ "#!/usr/bin/env python3\n"
1018
+ "import sys, os\n"
1019
+ f"sys.argv[0] = {fake!r}\n"
1020
+ "sys.argv.insert(0, sys.executable)\n"
1021
+ "os.execv(sys.executable, sys.argv)\n"
1022
+ )
1023
+ os.chmod(shim, 0o755)
1024
+ from wechatbridge.config import config
1025
+ orig = config.codex_binary_path
1026
+ config.codex_binary_path = shim
1027
+ try:
1028
+ display, artifacts = self._run("hi", "u-dedup-realpath")
1029
+ result_entries = [a for a in artifacts if a[0] == "_dedup_target.txt"]
1030
+ self.assertEqual(len(result_entries), 1, "no duplicate from fallback for realpath alias")
1031
+ finally:
1032
+ config.codex_binary_path = orig
1033
+
1034
+ def test_add_dirs_retry_argv_consistent(self):
1035
+ """Retry argv uses same validated add_dirs list as first run."""
1036
+ import os, datetime
1037
+ sd = os.path.join(self.td, _sanitize("u-retry-add"))
1038
+ os.makedirs(sd, exist_ok=True)
1039
+ from wechatbridge.codex import (
1040
+ ensure_user_codex, mark_initialized, _write_codex_thread_id,
1041
+ _has_codex_session,
1042
+ )
1043
+ mark_initialized(sd, backend="codex")
1044
+ tid = "aabbccdd-0000-0000-0000-000000000000"
1045
+ _write_codex_thread_id(sd, tid)
1046
+ now = datetime.datetime.now()
1047
+ rollout_dir = os.path.join(
1048
+ sd, ".codex", "sessions", now.strftime("%Y"),
1049
+ now.strftime("%m"), now.strftime("%d"),
1050
+ )
1051
+ os.makedirs(rollout_dir, exist_ok=True)
1052
+ with open(os.path.join(rollout_dir, f"rollout-x-{tid}.jsonl"), "w") as f:
1053
+ f.write("x")
1054
+ self.assertTrue(_has_codex_session(sd, tid))
1055
+
1056
+ from wechatbridge.runner_common import save_prefs
1057
+ save_prefs("u-retry-add", {"add_dirs": [sd]})
1058
+
1059
+ captured = []
1060
+ from wechatbridge import codex as codex_mod
1061
+
1062
+ async def spawn(*args, **kwargs):
1063
+ captured.append(list(args))
1064
+ cmd_str = str(args)
1065
+ if "resume" in cmd_str:
1066
+ from tests.test_codex import _FakeProc
1067
+ return _FakeProc("", "error: session not found", 1)
1068
+ from tests.test_codex import _FakeProc
1069
+ return _FakeProc(
1070
+ '{"type":"thread.started","thread_id":"new-tid"}\n'
1071
+ '{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}\n',
1072
+ "", 0,
1073
+ )
1074
+
1075
+ with mock.patch("asyncio.create_subprocess_exec", side_effect=spawn):
1076
+ display, _ = asyncio.run(codex_mod.run_codex("hi", "u-retry-add"))
1077
+ self.assertEqual(len(captured), 2)
1078
+ self.assertIn("--add-dir", captured[0])
1079
+ self.assertIn(sd, captured[0])
1080
+ self.assertIn("--add-dir", captured[1])
1081
+ self.assertIn(sd, captured[1])
1082
+
1083
+ def test_fallback_scan_file_limit(self):
1084
+ """When session_dir has >200 regular files, fallback scan returns empty."""
1085
+ sd = os.path.join(self.td, _sanitize("u-file-limit"))
1086
+ os.makedirs(sd, exist_ok=True)
1087
+ for i in range(205):
1088
+ with open(os.path.join(sd, f"f_{i}.txt"), "w") as f:
1089
+ f.write("x")
1090
+ from wechatbridge.codex import _snapshot_regular_files
1091
+ snap = _snapshot_regular_files([sd])
1092
+ self.assertEqual(snap, {}, ">200 files -> empty")
1093
+
1094
+ def test_fallback_scan_dir_limit(self):
1095
+ """When session_dir has >50 subdirectories, fallback scan returns empty."""
1096
+ sd = os.path.join(self.td, _sanitize("u-dir-limit"))
1097
+ os.makedirs(sd, exist_ok=True)
1098
+ for i in range(55):
1099
+ d = os.path.join(sd, f"dir_{i}")
1100
+ os.makedirs(d, exist_ok=True)
1101
+ with open(os.path.join(d, "file.txt"), "w") as f:
1102
+ f.write("x")
1103
+ from wechatbridge.codex import _snapshot_regular_files
1104
+ snap = _snapshot_regular_files([sd])
1105
+ self.assertEqual(snap, {}, ">50 dirs -> empty")
1106
+
1107
+ def test_fallback_scan_timeout(self):
1108
+ """When snapshot takes >2s, fallback scan returns empty."""
1109
+ from wechatbridge import codex as codex_mod
1110
+ original = codex_mod._snapshot_regular_files
1111
+
1112
+ def slow_snapshot(roots):
1113
+ import time
1114
+ time.sleep(2.5)
1115
+ return {"/fake/file.txt": 1.0}
1116
+
1117
+ codex_mod._snapshot_regular_files = slow_snapshot
1118
+ try:
1119
+ display, artifacts = self._run("hi", "u-scan-timeout", mode="ok")
1120
+ self.assertEqual(artifacts, [], "slow snapshot -> empty")
1121
+ finally:
1122
+ codex_mod._snapshot_regular_files = original
1123
+
1124
+ def test_fallback_scan_not_on_failure(self):
1125
+ """Fallback scan must NOT run when process fails."""
1126
+ display, artifacts = self._run("hi", "u-fail-noscan", mode="fail_first")
1127
+ self.assertTrue(display.startswith("❌"))
1128
+ self.assertEqual(artifacts, [])
1129
+
1130
+ def test_fallback_scan_not_on_timeout(self):
1131
+ """Fallback scan must NOT run when process times out."""
1132
+ from wechatbridge import codex as codex_mod
1133
+ display, artifacts = self._run("hi", "u-to-noscan", timeout=1, mode="timeout")
1134
+ self.assertIn("超时", display)
1135
+ self.assertEqual(artifacts, [])
1136
+
1137
+ def test_add_dirs_consistency_between_build_and_scan(self):
1138
+ """add_dirs in command builder and fallback scan use same validated paths."""
1139
+ from wechatbridge import codex as codex_mod
1140
+
1141
+ # Create a real directory that validate_add_dir would accept
1142
+ add_dir = os.path.join(self.td, "validated_workdir")
1143
+ os.makedirs(add_dir, exist_ok=True)
1144
+ # Write a file in add_dir with old mtime (should NOT be collected)
1145
+ old_file = os.path.join(add_dir, "old_in_add.txt")
1146
+ with open(old_file, "w") as f:
1147
+ f.write("old")
1148
+ os.utime(old_file, (1000000, 1000000))
1149
+
1150
+ # Mock validate_add_dir to return the resolved path (simulates valid dir)
1151
+ with mock.patch("wechatbridge.codex.validate_add_dir", return_value=(True, add_dir)):
1152
+ # Manually set prefs with raw add_dir to test resolution
1153
+ from wechatbridge.runner_common import save_prefs, get_session_dir
1154
+ save_prefs("u-add-consistency", {"add_dirs": [add_dir]})
1155
+
1156
+ display, artifacts = self._run("hi", "u-add-consistency")
1157
+ self.assertIn("first(", display)
1158
+ # old_in_add.txt should NOT be in artifacts (old mtime)
1159
+ names = {n for n, _ in artifacts}
1160
+ self.assertNotIn("old_in_add.txt", names)
1161
+
1162
+ # Verify the command used the resolved path
1163
+ sd = os.path.join(self.td, _sanitize("u-add-consistency"))
1164
+ self.assertTrue(os.path.isdir(sd))
1165
+
1166
+
911
1167
  class TestCodexEnvOverride(unittest.IsolatedAsyncioTestCase):
912
1168
  """CODEX_HOME must be overridden to session_dir/.codex, beating any global
913
1169
  CODEX_HOME from the service environment. The retry reuses the same env.
@@ -1,2 +1,2 @@
1
1
  """WeChatBridge — bridge WeChat messages to agy, Grok Build, or Codex CLIs."""
2
- __version__ = "1.4.4"
2
+ __version__ = "1.4.5"
@@ -22,7 +22,7 @@ from .runner_common import (
22
22
  sanitize_user_id, get_session_dir, ensure_session_dir, is_first_message, mark_initialized, clear_initialized,
23
23
  clean_output, load_prefs, save_prefs, is_dangerous, parse_model_effort,
24
24
  sanitize_env, terminate_process, update_active_prefs, is_bridge_formatted_reply,
25
- format_error, format_cli_error, format_model_label, EMPTY_REPLY, validate_add_dir,
25
+ format_error, format_cli_error, format_model_label, EMPTY_REPLY, validate_add_dir, path_is_under,
26
26
  )
27
27
 
28
28
  logger = logging.getLogger("codex_runner")
@@ -388,6 +388,7 @@ def _build_codex_command(prompt: str, prefs: dict, first: bool, thread_id: str =
388
388
  cmd += ["-c", f"model_reasoning_effort={effort}"]
389
389
 
390
390
  # add_dirs -> each --add-dir (codex 原生支持,比 grok 的"仅记录"强)
391
+ # add_dirs 已由 _resolve_add_dirs 通过 validate_add_dir 规范化
391
392
  for d in prefs.get("add_dirs", []) or []:
392
393
  if d:
393
394
  cmd += ["--add-dir", d]
@@ -411,7 +412,7 @@ def _collect_codex_artifact(path: str, session_dir: str, since: float, seen: set
411
412
  if not os.path.isabs(path):
412
413
  path = os.path.join(session_dir, path)
413
414
  try:
414
- path = os.path.abspath(path)
415
+ path = os.path.realpath(path)
415
416
  except (OSError, ValueError):
416
417
  return
417
418
  try:
@@ -428,6 +429,75 @@ def _collect_codex_artifact(path: str, session_dir: str, since: float, seen: set
428
429
  artifacts.append(key)
429
430
 
430
431
 
432
+ def _resolve_add_dirs(raw_dirs: list, session_dir: str, user_id: str = "") -> list:
433
+ """Validate add_dirs via validate_add_dir for command building + fallback scan.
434
+
435
+ Calls validate_add_dir once per directory to get resolved realpaths.
436
+ Ensures consistency between the --add-dir flags passed to Codex and the
437
+ allowed roots used by the artifact fallback scan.
438
+ """
439
+ validated = []
440
+ for d in raw_dirs or []:
441
+ if not d:
442
+ continue
443
+ try:
444
+ ok, result = validate_add_dir(d, user_id)
445
+ except Exception as e:
446
+ logger.warning("validate_add_dir failed for %s: %s", d, e)
447
+ continue
448
+ if ok and result:
449
+ validated.append(result)
450
+ return validated
451
+
452
+
453
+ _EXCLUDED_SCAN_NAMES = frozenset({".codex", ".git", "__pycache__", "node_modules", "venv", ".venv", "cache", ".cache"})
454
+
455
+
456
+ def _snapshot_regular_files(roots: list) -> dict:
457
+ """Snapshot regular files (path, mtime) under allowed roots for fallback scan.
458
+
459
+ Enforces limits: <=200 files, <=50 directories, <=2 seconds. Returns empty
460
+ dict if any limit is exceeded (never partial results).
461
+
462
+ ``os.walk(followlinks=False)`` naturally excludes symlink traversal;
463
+ ``_EXCLUDED_SCAN_NAMES`` skips internal metadata dirs; only regular files
464
+ (not dirs/symlinks/special) are collected.
465
+ """
466
+ snap = {}
467
+ t_start = time.monotonic()
468
+ file_count = 0
469
+ dir_count = 0
470
+ _MAX_FILES = 200
471
+ _MAX_DIRS = 50
472
+ _MAX_SCAN_TIME = 2.0
473
+ for root in roots:
474
+ if not os.path.isdir(root):
475
+ continue
476
+ for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
477
+ dir_count += 1
478
+ if dir_count > _MAX_DIRS:
479
+ return {}
480
+ if time.monotonic() - t_start > _MAX_SCAN_TIME:
481
+ return {}
482
+ # Exclude internal metadata directories in-place
483
+ dirnames[:] = [d for d in dirnames if d not in _EXCLUDED_SCAN_NAMES]
484
+ for fn in filenames:
485
+ file_count += 1
486
+ if file_count > _MAX_FILES:
487
+ return {}
488
+ fp = os.path.join(dirpath, fn)
489
+ try:
490
+ if not os.path.isfile(fp) or os.path.islink(fp):
491
+ continue
492
+ # Verify candidate is under one of the allowed roots
493
+ if not any(path_is_under(fp, r) for r in roots):
494
+ continue
495
+ snap[fp] = os.path.getmtime(fp)
496
+ except OSError:
497
+ continue
498
+ return snap
499
+
500
+
431
501
  def _parse_codex_output(stdout_text: str, session_dir: str, since: float = 0.0) -> tuple:
432
502
  """Parse codex JSONL output into (display_text, artifacts, thread_id, parse_failed).
433
503
 
@@ -561,6 +631,28 @@ def _extract_codex_error_messages(stdout_text: str) -> list:
561
631
  # codex CLI execution
562
632
  # ---------------------------------------------------------------------------
563
633
 
634
+
635
+ def _collect_fallback_artifacts(session_dir: str, snapshot_before: dict, t0: float) -> list:
636
+ """Scan session_dir for new files created during codex execution.
637
+
638
+ Only runs when: returncode==0, parse_failed==False, no structured artifacts.
639
+ Scans only session_dir (never add_dirs). Excludes internal metadata dirs.
640
+ Each candidate realpath is verified to be under session_dir.
641
+ """
642
+ _internal_names = {".codex_thread_id"}
643
+ fallback = []
644
+ fallback_seen = set()
645
+ for fp, fmtime in _snapshot_regular_files([session_dir]).items():
646
+ if fmtime >= t0 and fp not in snapshot_before:
647
+ name = os.path.basename(fp)
648
+ if name in _internal_names or name.startswith(".initialized."):
649
+ continue
650
+ key = (name, fp)
651
+ if key not in fallback_seen:
652
+ fallback_seen.add(key)
653
+ fallback.append(key)
654
+ return fallback
655
+
564
656
  async def run_codex(prompt: str, user_id: str, timeout: int = None) -> tuple:
565
657
  """Execute codex CLI for a given user message.
566
658
 
@@ -610,9 +702,15 @@ async def run_codex(prompt: str, user_id: str, timeout: int = None) -> tuple:
610
702
  first = True
611
703
 
612
704
  prefs = load_prefs(user_id)
705
+ # 验证并规范化 add_dirs,确保命令构建与 fallback 扫描使用同一批目录
706
+ validated_add_dirs = _resolve_add_dirs(prefs.get("add_dirs"), session_dir, user_id)
707
+ prefs["add_dirs"] = validated_add_dirs
613
708
  # persona 经 AGENTS.md 由 codex 自动读取,这里无需注入 argv
614
709
  cmd = _build_codex_command(prompt, prefs, first, thread_id)
615
710
 
711
+ # 执行前快照现有文件,用于 shell 产物 fallback 扫描(仅 session_dir)
712
+ snapshot_before = _snapshot_regular_files([session_dir])
713
+
616
714
  if first:
617
715
  logger.info("First message for user %s, running: codex exec ...", user_id)
618
716
  else:
@@ -733,6 +831,12 @@ async def run_codex(prompt: str, user_id: str, timeout: int = None) -> tuple:
733
831
  mark_initialized(session_dir, backend="codex")
734
832
  if r_thread_id:
735
833
  _write_codex_thread_id(session_dir, r_thread_id)
834
+ # Fallback: shell 产物扫描(仅重试成功且无结构化 artifacts 时触发)
835
+ if not r_failed and not r_artifacts:
836
+ fallback = _collect_fallback_artifacts(session_dir, snapshot_before, t0)
837
+ if fallback:
838
+ r_artifacts = fallback
839
+ logger.info("Fallback scan collected %d additional files on retry", len(r_artifacts))
736
840
  elapsed = time.time() - t0
737
841
  logger.info(
738
842
  "codex retry done: user=%s elapsed=%.1fs artifacts=%d output=%d chars failed=%s",
@@ -746,6 +850,13 @@ async def run_codex(prompt: str, user_id: str, timeout: int = None) -> tuple:
746
850
  if parsed_thread_id:
747
851
  _write_codex_thread_id(session_dir, parsed_thread_id)
748
852
 
853
+ # Fallback: shell 产物扫描(仅在成功且无结构化 artifacts 时触发)
854
+ if not failed and not artifacts:
855
+ fallback = _collect_fallback_artifacts(session_dir, snapshot_before, t0)
856
+ if fallback:
857
+ artifacts = fallback
858
+ logger.info("Fallback scan collected %d additional files", len(artifacts))
859
+
749
860
  elapsed = time.time() - t0
750
861
  logger.info(
751
862
  "codex done: user=%s elapsed=%.1fs artifacts=%d output=%d chars failed=%s",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wechatbridge-cli
3
- Version: 1.4.4
3
+ Version: 1.4.5
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