wechatbridge-cli 1.4.3__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.
- {wechatbridge_cli-1.4.3/wechatbridge_cli.egg-info → wechatbridge_cli-1.4.5}/PKG-INFO +1 -1
- {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.5}/tests/test_codex.py +256 -0
- {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.5}/tests/test_hardening.py +249 -1
- {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.5}/wechatbridge/__init__.py +1 -1
- {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.5}/wechatbridge/agy.py +8 -2
- {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.5}/wechatbridge/codex.py +113 -2
- {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.5}/wechatbridge/grok.py +20 -12
- {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.5}/wechatbridge/ilink.py +26 -6
- {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.5}/wechatbridge/main.py +34 -13
- {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.5}/wechatbridge/runner_common.py +24 -0
- {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.5/wechatbridge_cli.egg-info}/PKG-INFO +1 -1
- {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.5}/LICENSE +0 -0
- {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.5}/README.md +0 -0
- {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.5}/pyproject.toml +0 -0
- {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.5}/setup.cfg +0 -0
- {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.5}/wechatbridge/__main__.py +0 -0
- {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.5}/wechatbridge/config.py +0 -0
- {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.5}/wechatbridge/update_check.py +0 -0
- {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.5}/wechatbridge_cli.egg-info/SOURCES.txt +0 -0
- {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.5}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
- {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.5}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
- {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.5}/wechatbridge_cli.egg-info/requires.txt +0 -0
- {wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.5}/wechatbridge_cli.egg-info/top_level.txt +0 -0
|
@@ -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.
|
|
@@ -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
|
-
|
|
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.
|
|
2
|
+
__version__ = "1.4.5"
|
|
@@ -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
|
-
|
|
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)
|
|
@@ -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.
|
|
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",
|
|
@@ -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
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.5}/wechatbridge_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{wechatbridge_cli-1.4.3 → wechatbridge_cli-1.4.5}/wechatbridge_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|