wechatbridge-cli 1.7.0__tar.gz → 1.7.1__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 (28) hide show
  1. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/PKG-INFO +1 -1
  2. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/tests/test_hardening.py +256 -0
  3. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge/__init__.py +1 -1
  4. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge/agy.py +115 -27
  5. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge/codex.py +28 -4
  6. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge/dsh.py +13 -70
  7. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge/grok.py +28 -3
  8. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge/ilink.py +40 -6
  9. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge/main.py +57 -17
  10. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge/price.py +96 -22
  11. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge/runner_common.py +258 -0
  12. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge_cli.egg-info/PKG-INFO +1 -1
  13. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/LICENSE +0 -0
  14. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/README.md +0 -0
  15. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/pyproject.toml +0 -0
  16. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/setup.cfg +0 -0
  17. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/tests/test_codex.py +0 -0
  18. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/tests/test_codex_auth.py +0 -0
  19. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/tests/test_dsh.py +0 -0
  20. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/tests/test_grok_auth.py +0 -0
  21. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge/__main__.py +0 -0
  22. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge/config.py +0 -0
  23. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge/update_check.py +0 -0
  24. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge_cli.egg-info/SOURCES.txt +0 -0
  25. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
  26. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
  27. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge_cli.egg-info/requires.txt +0 -0
  28. {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/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.7.0
3
+ Version: 1.7.1
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
@@ -2983,5 +2983,261 @@ class TestAgyStreamRetry(unittest.IsolatedAsyncioTestCase):
2983
2983
  mock_sleep.assert_not_awaited()
2984
2984
 
2985
2985
 
2986
+ class TestAuditHardeningFixes(unittest.IsolatedAsyncioTestCase):
2987
+ """Regression tests for deep-audit security and correctness fixes."""
2988
+
2989
+ def setUp(self):
2990
+ from wechatbridge.config import config as cfg
2991
+ self._cfg = cfg
2992
+ self._tmp = tempfile.TemporaryDirectory()
2993
+ self.session_base = self._tmp.name
2994
+ self._orig_base = cfg.session_base_dir
2995
+ self._orig_backend = cfg.backend
2996
+ cfg.session_base_dir = self.session_base
2997
+
2998
+ def tearDown(self):
2999
+ self._cfg.session_base_dir = self._orig_base
3000
+ self._cfg.backend = self._orig_backend
3001
+ self._tmp.cleanup()
3002
+
3003
+ async def test_agy_send_artifacts_allows_session_dir_and_blocks_sensitive_or_hardlink(self):
3004
+ from wechatbridge import main as main_mod
3005
+ from wechatbridge.runner_common import ensure_session_dir
3006
+
3007
+ self._cfg.backend = "agy"
3008
+ uid = "u-agy-sec"
3009
+ sdir = ensure_session_dir(uid)
3010
+
3011
+ # Normal artifact directly in session_dir (should be allowed)
3012
+ good_file = os.path.join(sdir, "report.pdf")
3013
+ with open(good_file, "wb") as f:
3014
+ f.write(b"%PDF-1.4 ok")
3015
+
3016
+ # Normal artifact in .gemini/antigravity-cli/scratch (should be allowed)
3017
+ scratch_dir = os.path.join(sdir, ".gemini", "antigravity-cli", "scratch")
3018
+ os.makedirs(scratch_dir, exist_ok=True)
3019
+ scratch_file = os.path.join(scratch_dir, "chart.png")
3020
+ with open(scratch_file, "wb") as f:
3021
+ f.write(b"PNGDATA")
3022
+
3023
+ # Sensitive files inside session_dir (must be blocked)
3024
+ prefs_file = os.path.join(sdir, "prefs.json")
3025
+ with open(prefs_file, "w") as f:
3026
+ f.write('{"model": "gemini-4-argon"}')
3027
+ gemini_dir = os.path.join(sdir, ".gemini")
3028
+ os.makedirs(gemini_dir, exist_ok=True)
3029
+ token_file = os.path.join(gemini_dir, "antigravity-oauth-token")
3030
+ with open(token_file, "w") as f:
3031
+ f.write("secret-oauth-token")
3032
+
3033
+ # Hardlink inside session_dir pointing to external file (must be blocked)
3034
+ ext_file = os.path.join(self.session_base, "external_secret.txt")
3035
+ with open(ext_file, "w") as f:
3036
+ f.write("top-secret")
3037
+ hardlink_file = os.path.join(sdir, "linked.txt")
3038
+ os.link(ext_file, hardlink_file)
3039
+
3040
+ client = mock.MagicMock()
3041
+ client.state.baseurl = "https://ilink"
3042
+ client.state.bot_token = "tok"
3043
+ client.send_media = AsyncMock(return_value=True)
3044
+ client.send_message = AsyncMock(return_value=True)
3045
+
3046
+ await main_mod.send_artifacts_back(
3047
+ artifacts=[
3048
+ ("report.pdf", good_file),
3049
+ ("chart.png", scratch_file),
3050
+ ("prefs.json", prefs_file),
3051
+ ("antigravity-oauth-token", token_file),
3052
+ ("linked.txt", hardlink_file),
3053
+ ],
3054
+ client=client,
3055
+ from_user=uid,
3056
+ context_token="ctx",
3057
+ )
3058
+
3059
+ sent_paths = [c.kwargs["path"] for c in client.send_media.await_args_list]
3060
+ self.assertEqual(
3061
+ sent_paths,
3062
+ [os.path.realpath(good_file), os.path.realpath(scratch_file)],
3063
+ )
3064
+
3065
+ def test_agy_extract_artifacts_balanced_parens_and_model_tsv_parsing(self):
3066
+ from wechatbridge import agy as agy_mod
3067
+
3068
+ text = (
3069
+ "已生成 [report (1).pdf](file:///tmp/sess/report%20(1).pdf) "
3070
+ "和 [v2 [final].docx](file:///tmp/sess/v2%20[final].docx)。"
3071
+ )
3072
+ arts = agy_mod.extract_artifacts(text)
3073
+ self.assertEqual(
3074
+ arts,
3075
+ [
3076
+ ("report (1).pdf", "/tmp/sess/report (1).pdf"),
3077
+ ("v2 [final].docx", "/tmp/sess/v2 [final].docx"),
3078
+ ],
3079
+ )
3080
+
3081
+ tsv_models = (
3082
+ "gemini-4-argon\tGemini 3.1 Pro (High)\n"
3083
+ "claude-opus-4-6-thinking\tClaude Opus 4.6 (Thinking)\n"
3084
+ )
3085
+ self.assertEqual(
3086
+ agy_mod._parse_agy_model_ids(tsv_models),
3087
+ ["gemini-4-argon", "claude-opus-4-6-thinking"],
3088
+ )
3089
+
3090
+ async def test_planning_and_fast_toggle_and_clear_resets_mode(self):
3091
+ from wechatbridge import agy as agy_mod
3092
+ from wechatbridge.runner_common import load_prefs
3093
+
3094
+ uid = "u-toggle"
3095
+ r1 = await agy_mod.handle_slash_command("/planning", uid)
3096
+ self.assertIn("已开启规划模式", r1)
3097
+ self.assertEqual(load_prefs(uid).get("mode"), "plan")
3098
+
3099
+ # Calling /planning again toggles it off
3100
+ r2 = await agy_mod.handle_slash_command("/planning", uid)
3101
+ self.assertIn("已关闭规划模式", r2)
3102
+ self.assertEqual(load_prefs(uid).get("mode"), "")
3103
+
3104
+ # Enable again and verify /clear resets sticky mode
3105
+ await agy_mod.handle_slash_command("/planning", uid)
3106
+ self.assertEqual(load_prefs(uid).get("mode"), "plan")
3107
+ await agy_mod.handle_slash_command("/clear", uid)
3108
+ self.assertEqual(load_prefs(uid).get("mode"), "")
3109
+
3110
+ # /fast off
3111
+ await agy_mod.handle_slash_command("/fast", uid)
3112
+ self.assertEqual(load_prefs(uid).get("effort"), "low")
3113
+ r_fast_off = await agy_mod.handle_slash_command("/fast off", uid)
3114
+ self.assertIn("已关闭快速模式", r_fast_off)
3115
+ self.assertEqual(load_prefs(uid).get("effort"), "")
3116
+
3117
+ def test_sanitize_prompt_at_paths_allows_session_and_add_dirs_blocks_external(self):
3118
+ from wechatbridge.runner_common import _sanitize_prompt_at_paths, ensure_session_dir
3119
+
3120
+ sdir = ensure_session_dir("u-at")
3121
+ extra_dir = os.path.join(self.session_base, "allowed_extra")
3122
+ os.makedirs(extra_dir, exist_ok=True)
3123
+ prompt = (
3124
+ f"请看 @{sdir}/img.png 和 @{extra_dir}/doc.md "
3125
+ "不要读 @/etc/passwd 或 @../../etc/shadow"
3126
+ )
3127
+ sanitized = _sanitize_prompt_at_paths(prompt, sdir, extra_roots=[extra_dir])
3128
+ self.assertIn(f"@{sdir}/img.png", sanitized)
3129
+ self.assertIn(f"@{extra_dir}/doc.md", sanitized)
3130
+ self.assertNotIn("/etc/passwd", sanitized)
3131
+ self.assertNotIn("/etc/shadow", sanitized)
3132
+ self.assertEqual(sanitized.count("[blocked-path]"), 2)
3133
+
3134
+ async def test_ilink_get_updates_raises_on_5xx_and_encrypt_roundtrip(self):
3135
+ import httpx
3136
+ from wechatbridge.ilink import ILinkClient, _encrypt_aes_ecb, _decrypt_aes_ecb
3137
+
3138
+ key = os.urandom(16)
3139
+ for length in (0, 1, 15, 16, 17, 32, 100):
3140
+ raw = os.urandom(length)
3141
+ enc = _encrypt_aes_ecb(raw, key)
3142
+ self.assertEqual(_decrypt_aes_ecb(enc, key), raw)
3143
+
3144
+ client = ILinkClient()
3145
+ try:
3146
+ req = httpx.Request("POST", "https://ilink/ilink/bot/getupdates")
3147
+ resp_502 = httpx.Response(502, request=req)
3148
+ client.http_client.post = AsyncMock(
3149
+ side_effect=httpx.HTTPStatusError("502 Bad Gateway", request=req, response=resp_502)
3150
+ )
3151
+ client.state.clear = mock.MagicMock()
3152
+ with self.assertRaises(httpx.HTTPStatusError):
3153
+ await client.get_updates("buf1", "https://ilink", "tok")
3154
+ client.state.clear.assert_not_called()
3155
+ finally:
3156
+ await client.close()
3157
+
3158
+ async def test_price_http_error_does_not_leak_url_or_appkey(self):
3159
+ import httpx
3160
+ from wechatbridge import price as price_mod
3161
+
3162
+ req = httpx.Request(
3163
+ "GET",
3164
+ "https://openapi.dataoke.com/api/goods/get-goods-details?appKey=SECRET_DT_KEY_123&id=1",
3165
+ )
3166
+ err = httpx.ConnectError(
3167
+ "Connection failed for https://openapi.dataoke.com/api?appKey=SECRET_DT_KEY_123",
3168
+ request=req,
3169
+ )
3170
+ with mock.patch.object(price_mod, "dt_configured", return_value=True), \
3171
+ mock.patch.object(price_mod, "query_tb_item", side_effect=err):
3172
+ reply = await price_mod.handle_price_query({"kind": "tb_item", "item_id": "1"})
3173
+ self.assertNotIn("SECRET_DT_KEY_123", reply)
3174
+ self.assertIn("比价接口请求失败", reply)
3175
+
3176
+ with self.assertRaises(ValueError):
3177
+ price_mod._resolve_short_link("http://127.0.0.1:8080/admin")
3178
+ self.assertFalse(price_mod._is_allowed_short_url("https://evil.u.jd.com/abc"))
3179
+ self.assertFalse(price_mod._is_allowed_short_url("https://a.3.cn/xyz"))
3180
+ self.assertTrue(price_mod._is_allowed_short_url("https://u.jd.com/abc"))
3181
+
3182
+ def test_ensure_user_gemini_refreshes_stale_oauth_token_and_blocks_copied_secrets(self):
3183
+ from wechatbridge import agy as agy_mod
3184
+ from wechatbridge.runner_common import is_sensitive_artifact_path, _sanitize_prompt_at_paths
3185
+
3186
+ fake_host_home = os.path.join(self.session_base, "host_home")
3187
+ host_agy = os.path.join(fake_host_home, ".gemini", "antigravity-cli")
3188
+ os.makedirs(host_agy, exist_ok=True)
3189
+ src_token = os.path.join(host_agy, "antigravity-oauth-token")
3190
+ with open(src_token, "w") as f:
3191
+ f.write("old-token-value-111111")
3192
+
3193
+ def _fake_expanduser(p: str) -> str:
3194
+ if p.startswith("~/"):
3195
+ return os.path.join(fake_host_home, p[2:])
3196
+ return p
3197
+
3198
+ with mock.patch("os.path.expanduser", side_effect=_fake_expanduser):
3199
+ sdir = agy_mod.ensure_user_gemini("u-tok-refresh")
3200
+ dst_token = os.path.join(sdir, ".gemini", "antigravity-cli", "antigravity-oauth-token")
3201
+ with open(dst_token) as f:
3202
+ self.assertEqual(f.read(), "old-token-value-111111")
3203
+
3204
+ # Make dst older and update src token
3205
+ os.utime(dst_token, (1000.0, 1000.0))
3206
+ with open(src_token, "w") as f:
3207
+ f.write("refreshed-token-value-222222")
3208
+ os.utime(src_token, (2000.0, 2000.0))
3209
+
3210
+ agy_mod.ensure_user_gemini("u-tok-refresh")
3211
+ with open(dst_token) as f:
3212
+ self.assertEqual(f.read(), "refreshed-token-value-222222")
3213
+
3214
+ # Renamed / copied credentials must still be blocked by is_sensitive_artifact_path
3215
+ copied_secret = os.path.join(sdir, "token.copy")
3216
+ with open(copied_secret, "w") as f:
3217
+ f.write("refreshed-token-value-222222")
3218
+ self.assertTrue(is_sensitive_artifact_path(copied_secret, sdir))
3219
+
3220
+ auth_bak = os.path.join(sdir, "auth.json.bak")
3221
+ with open(auth_bak, "w") as f:
3222
+ f.write("{}")
3223
+ self.assertTrue(is_sensitive_artifact_path(auth_bak, sdir))
3224
+
3225
+ scratch_settings = os.path.join(sdir, ".gemini", "antigravity-cli", "scratch", "settings.json")
3226
+ os.makedirs(os.path.dirname(scratch_settings), exist_ok=True)
3227
+ with open(scratch_settings, "w") as f:
3228
+ f.write("{}")
3229
+ self.assertTrue(is_sensitive_artifact_path(scratch_settings, sdir))
3230
+
3231
+ # @<path> sanitization for bare sensitive names, URL-encoded traversal, and URLs
3232
+ p_out = _sanitize_prompt_at_paths(
3233
+ "read @prefs.json @auth.json.bak @..%2f..%2fetc%2fpasswd @https://evil.com/a",
3234
+ sdir,
3235
+ )
3236
+ self.assertEqual(
3237
+ p_out,
3238
+ "read [blocked-path] [blocked-path] [blocked-path] [blocked-path]",
3239
+ )
3240
+
3241
+
2986
3242
  if __name__ == "__main__":
2987
3243
  unittest.main()
@@ -1,2 +1,2 @@
1
1
  """WeChatBridge — bridge WeChat messages to agy, Grok Build, or Codex CLIs."""
2
- __version__ = "1.7.0"
2
+ __version__ = "1.7.1"
@@ -10,13 +10,16 @@ import re
10
10
  import shutil
11
11
  import signal
12
12
  import sys
13
+ import tempfile
13
14
  import time
14
15
  from urllib.parse import unquote
15
16
 
16
17
  from .config import config
17
18
  from .runner_common import (
19
+ MAX_ADD_DIRS,
20
+ _sanitize_prompt_at_paths,
18
21
  sanitize_user_id, get_session_dir, ensure_session_dir, is_first_message, mark_initialized, clear_initialized,
19
- clean_output, load_prefs, save_prefs, is_dangerous, parse_model_effort,
22
+ clean_output, load_prefs, save_prefs, is_dangerous, is_sensitive_artifact_path, parse_model_effort,
20
23
  sanitize_env, terminate_process, update_active_prefs,
21
24
  format_error, format_cli_error, _classify_cli_error, is_bridge_formatted_reply, EMPTY_REPLY,
22
25
  ANSI_RE, HTML_TAG_RE, validate_add_dir,
@@ -27,22 +30,26 @@ logger = logging.getLogger("agy_runner")
27
30
  # execve 单参数上限(Linux MAX_ARG_STRLEN = 128KB),留安全余量
28
31
  _MAX_ARG_BYTES = 120 * 1024
29
32
 
33
+ # Matches markdown [name](file:///path) while supporting one level of balanced
34
+ # brackets in display name and one level of balanced parentheses in path (e.g.
35
+ # "report (1).pdf"), without greedily spanning across multiple links on a line.
36
+ _FILE_LINK_RE = re.compile(
37
+ r"\[((?:[^\[\]]+|\[[^\[\]]*\])+)\]\(file:///((?:[^()\s]+|\([^()\s]*\))+)\)"
38
+ )
39
+
30
40
 
31
41
  def extract_artifacts(text: str) -> list[tuple[str, str]]:
32
42
  """Extract (name, absolute_path) tuples from markdown file:/// links.
33
43
 
34
- Uses regex ``\\[([^\\]]+)\\](file:///([^)]+))`` to find agy-generated
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.
44
+ Supports balanced parentheses in filenames (e.g. ``report (1).pdf``) and
45
+ URL-decodes (``urllib.parse.unquote``) percent-encoded spaces / CJK.
46
+ Returns a deduplicated, order-preserved list.
40
47
  """
41
48
  if not text:
42
49
  return []
43
50
  seen = set()
44
51
  result = []
45
- for match in re.finditer(r"\[([^\]]+)\]\(file:///([^)]+)\)", text):
52
+ for match in _FILE_LINK_RE.finditer(text):
46
53
  name = unquote(match.group(1).split("#")[0])
47
54
  path_part = unquote(match.group(2).split("#")[0])
48
55
  abs_path = path_part if path_part.startswith("/") else "/" + path_part
@@ -88,14 +95,38 @@ def ensure_user_gemini(user_id: str) -> str:
88
95
  except OSError:
89
96
  pass
90
97
 
91
- # Copy global auth token if not yet present
98
+ # Copy or refresh global auth token when missing or older than host token
92
99
  # agy standard auth token path, managed by agy CLI
93
100
  token_src = os.path.expanduser("~/.gemini/antigravity-cli/antigravity-oauth-token")
94
101
  token_dst = os.path.join(antigravity_dir, "antigravity-oauth-token")
95
- if not os.path.exists(token_dst) and os.path.exists(token_src):
102
+ if os.path.exists(token_src):
96
103
  try:
97
- shutil.copy(token_src, token_dst)
98
- os.chmod(token_dst, 0o600)
104
+ src_stat = os.stat(token_src)
105
+ need_copy = not os.path.exists(token_dst)
106
+ if not need_copy and src_stat.st_size > 0:
107
+ try:
108
+ dst_stat = os.stat(token_dst)
109
+ if src_stat.st_mtime > dst_stat.st_mtime or dst_stat.st_size == 0:
110
+ need_copy = True
111
+ except OSError:
112
+ need_copy = True
113
+ if need_copy and src_stat.st_size > 0:
114
+ fd, tmp_token = tempfile.mkstemp(
115
+ dir=antigravity_dir, prefix=".oauth-token.", suffix=".tmp"
116
+ )
117
+ try:
118
+ with os.fdopen(fd, "wb") as fout:
119
+ with open(token_src, "rb") as fin:
120
+ shutil.copyfileobj(fin, fout)
121
+ os.chmod(tmp_token, 0o600)
122
+ os.replace(tmp_token, token_dst)
123
+ except OSError:
124
+ if os.path.exists(tmp_token):
125
+ try:
126
+ os.unlink(tmp_token)
127
+ except OSError:
128
+ pass
129
+ raise
99
130
  except OSError as e:
100
131
  logger.warning("Failed to copy auth token for %s: %s", user_id, e)
101
132
 
@@ -178,15 +209,21 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
178
209
  cmd += ["--effort", effort]
179
210
  if prefs.get("mode"):
180
211
  cmd += ["--mode", prefs["mode"]]
181
- for d in prefs.get("add_dirs", []):
212
+ valid_add_dirs = []
213
+ for d in (prefs.get("add_dirs", []) or [])[:MAX_ADD_DIRS]:
182
214
  if not d:
183
215
  continue
184
216
  ok, resolved = validate_add_dir(d, user_id)
185
217
  if ok:
218
+ valid_add_dirs.append(resolved)
186
219
  cmd += ["--add-dir", resolved]
187
220
  else:
188
221
  logger.warning("Skipping disallowed add_dir for %s: %s (%s)", user_id, d, resolved)
189
222
 
223
+ safe_prompt = _sanitize_prompt_at_paths(
224
+ prompt, session_dir, extra_roots=valid_add_dirs
225
+ )
226
+
190
227
  if first:
191
228
  logger.info(
192
229
  "First message for user %s, running: agy -p ...", user_id
@@ -198,7 +235,7 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
198
235
  user_id,
199
236
  )
200
237
 
201
- cmd += ["-p", prompt]
238
+ cmd += ["-p", safe_prompt]
202
239
 
203
240
  process = None
204
241
  try:
@@ -226,7 +263,11 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
226
263
  stderr_text = stderr_bytes.decode("utf-8", errors="replace").strip()
227
264
 
228
265
  # A: Extract artifacts from raw stdout (before clean_output!)
229
- artifacts = extract_artifacts(stdout_text)
266
+ artifacts = [
267
+ (n, p)
268
+ for n, p in extract_artifacts(stdout_text)
269
+ if not is_sensitive_artifact_path(p, session_dir)
270
+ ]
230
271
 
231
272
  # B: (disabled) Scratch diff via before/after snapshots is removed because
232
273
  # multi-user shared scratch causes cross-user artifact leakage.
@@ -237,6 +278,7 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
237
278
  display = clean_output(stdout_text) or EMPTY_REPLY
238
279
 
239
280
  # Strip file:/// links from display to avoid leaking server paths
281
+ display = _FILE_LINK_RE.sub(r"[\1]", display)
240
282
  display = re.sub(
241
283
  r"\[([^\]]+)\]\(file:///[^)]+\)",
242
284
  r"[\1]",
@@ -293,8 +335,13 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
293
335
  r_stderr_text = r_stderr.decode("utf-8", errors="replace").strip()
294
336
  # Any useful stdout after retry counts as recovered (agy may exit non-zero)
295
337
  if r_stdout_text:
296
- r_artifacts = extract_artifacts(r_stdout_text)
338
+ r_artifacts = [
339
+ (n, p)
340
+ for n, p in extract_artifacts(r_stdout_text)
341
+ if not is_sensitive_artifact_path(p, session_dir)
342
+ ]
297
343
  r_display = clean_output(r_stdout_text) or EMPTY_REPLY
344
+ r_display = _FILE_LINK_RE.sub(r"[\1]", r_display)
298
345
  r_display = re.sub(r"\[([^\]]+)\]\(file:///[^)]+\)", r"[\1]", r_display)
299
346
  if (
300
347
  first
@@ -310,9 +357,10 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
310
357
  ), []
311
358
  return format_cli_error(r_stderr_text or stderr_text, backend="agy"), []
312
359
  return format_cli_error(stderr_text, backend="agy"), []
313
- # Non-zero exit: never treat raw stdout as a normal success reply
360
+ # Non-zero exit: never treat raw stdout as a normal success reply,
361
+ # but preserve any valid non-sensitive artifacts already emitted.
314
362
  raw = stderr_text or stdout_text or display or "process exited abnormally"
315
- return format_cli_error(raw, backend="agy"), []
363
+ return format_cli_error(raw, backend="agy"), artifacts
316
364
 
317
365
  # Success path only — never mark on ❌/🔔 error/throttle bubbles
318
366
  if first and display != EMPTY_REPLY and not is_bridge_formatted_reply(display):
@@ -524,26 +572,44 @@ def handle_persona(args: str, user_id: str) -> str:
524
572
 
525
573
 
526
574
  def _cmd_clear(user_id: str) -> str:
527
- """Handle /clear or /new: delete .initialized flag to start fresh."""
575
+ """Handle /clear or /new: delete .initialized flag to start fresh and reset sticky plan mode."""
528
576
  session_dir = get_session_dir(user_id)
529
577
  clear_initialized(session_dir, backend="agy")
578
+ prefs_path = os.path.join(session_dir, "prefs.json")
579
+ if os.path.exists(prefs_path):
580
+ prefs = load_prefs(user_id)
581
+ if prefs.get("mode"):
582
+ update_active_prefs(user_id, mode="")
530
583
  return "✅ **对话已重置** ✅"
531
584
 
532
585
 
533
- def _cmd_fast(user_id: str) -> str:
534
- """Handle /fast: set effort=low (scoped to current backend)."""
586
+ _OFF_ARGS = frozenset({"off", "0", "false", "no", "normal", "default", "reset", "clear", "agent"})
587
+
588
+
589
+ def _cmd_fast(user_id: str, args: str = "") -> str:
590
+ """Handle /fast [on|off]: set effort=low or toggle off (scoped to current backend)."""
591
+ sub = (args or "").strip().lower()
592
+ prefs = load_prefs(user_id)
593
+ if sub in _OFF_ARGS or (not sub and prefs.get("effort") == "low"):
594
+ update_active_prefs(user_id, effort="")
595
+ return "✅ **已关闭快速模式** ✅"
535
596
  update_active_prefs(user_id, effort="low")
536
597
  return "✅ **已开启快速模式** ✅"
537
598
 
538
599
 
539
- def _cmd_planning(user_id: str) -> str:
540
- """Handle /planning: set mode=plan (scoped to current backend)."""
600
+ def _cmd_planning(user_id: str, args: str = "") -> str:
601
+ """Handle /planning [on|off]: set mode=plan or toggle off (scoped to current backend)."""
602
+ sub = (args or "").strip().lower()
603
+ prefs = load_prefs(user_id)
604
+ if sub in _OFF_ARGS or (not sub and prefs.get("mode") == "plan"):
605
+ update_active_prefs(user_id, mode="")
606
+ return "✅ **已关闭规划模式** ✅"
541
607
  update_active_prefs(user_id, mode="plan")
542
608
  return "✅ **已开启规划模式** ✅"
543
609
 
544
610
 
545
611
  def _cmd_add_dir(args: str, user_id: str) -> str:
546
- """Handle /add-dir <path>: add path to add_dirs list (dedup, validated)."""
612
+ """Handle /add-dir <path>: add path to add_dirs list (dedup, validated, capped)."""
547
613
  path = args.strip()
548
614
  if not path:
549
615
  return "❌ **缺少参数** ❌\n\n`/add-dir <路径>`"
@@ -554,12 +620,34 @@ def _cmd_add_dir(args: str, user_id: str) -> str:
554
620
  prefs = load_prefs(user_id)
555
621
  dirs = prefs.get("add_dirs", [])
556
622
  if resolved not in dirs:
623
+ if len(dirs) >= MAX_ADD_DIRS:
624
+ return f"❌ **工作目录数量已达上限 ({MAX_ADD_DIRS})** ❌"
557
625
  dirs.append(resolved)
558
626
  prefs["add_dirs"] = dirs
559
627
  save_prefs(user_id, prefs)
560
628
  return f"✅ **已添加工作目录** ✅\n\n```\n{resolved}\n```"
561
629
 
562
630
 
631
+ def _parse_agy_model_ids(output: str) -> list[str]:
632
+ """Extract canonical model IDs from ``agy models`` output.
633
+
634
+ ``agy models`` outputs ``<model_id>\\t<Display Name>`` per line (e.g.
635
+ ``gemini-4-argon-high\\tGemini 4 Argon (High)``). Strip the display column
636
+ and any status/banner lines so only valid model slugs are returned.
637
+ """
638
+ models: list[str] = []
639
+ for raw_line in (output or "").split("\n"):
640
+ line = raw_line.strip()
641
+ if not line:
642
+ continue
643
+ if line.lower().startswith(("fetching available models", "available models", "error:")):
644
+ continue
645
+ model_id = line.split("\t", 1)[0].strip()
646
+ if model_id and " " not in model_id:
647
+ models.append(model_id)
648
+ return models
649
+
650
+
563
651
  async def _cmd_model(args: str, user_id: str) -> str:
564
652
  """Handle /model <name>: validate against agy models list, then save.
565
653
 
@@ -576,7 +664,7 @@ async def _cmd_model(args: str, user_id: str) -> str:
576
664
  if output.startswith("[error]") or is_bridge_formatted_reply(output):
577
665
  return "❌ **无法获取模型列表** ❌"
578
666
 
579
- models = [line.strip() for line in output.split("\n") if line.strip()]
667
+ models = _parse_agy_model_ids(output)
580
668
 
581
669
  # Exact match
582
670
  if name in models:
@@ -652,10 +740,10 @@ async def handle_slash_command(text: str, user_id: str) -> str | None:
652
740
  return _cmd_clear(user_id)
653
741
 
654
742
  if cmd == "/fast":
655
- return _cmd_fast(user_id)
743
+ return _cmd_fast(user_id, args)
656
744
 
657
745
  if cmd == "/planning":
658
- return _cmd_planning(user_id)
746
+ return _cmd_planning(user_id, args)
659
747
 
660
748
  if cmd == "/model":
661
749
  return await _cmd_model(args, user_id)
@@ -25,6 +25,7 @@ from .runner_common import (
25
25
  clean_output, load_prefs, save_prefs, is_dangerous, parse_model_effort,
26
26
  sanitize_env, terminate_process, update_active_prefs, is_bridge_formatted_reply,
27
27
  format_error, format_cli_error, format_model_label, EMPTY_REPLY, validate_add_dir, path_is_under,
28
+ MAX_ADD_DIRS, is_sensitive_artifact_path, _sanitize_prompt_at_paths,
28
29
  )
29
30
 
30
31
  logger = logging.getLogger("codex_runner")
@@ -518,6 +519,8 @@ def _collect_codex_artifact(path: str, session_dir: str, since: float, seen: set
518
519
  path = os.path.realpath(path)
519
520
  except (OSError, ValueError):
520
521
  return
522
+ if is_sensitive_artifact_path(path, session_dir):
523
+ return
521
524
  try:
522
525
  mtime = os.path.getmtime(path)
523
526
  except OSError:
@@ -540,7 +543,7 @@ def _resolve_add_dirs(raw_dirs: list, session_dir: str, user_id: str = "") -> li
540
543
  allowed roots used by the artifact fallback scan.
541
544
  """
542
545
  validated = []
543
- for d in raw_dirs or []:
546
+ for d in (raw_dirs or [])[:MAX_ADD_DIRS]:
544
547
  if not d:
545
548
  continue
546
549
  try:
@@ -688,6 +691,11 @@ def _parse_codex_output(stdout_text: str, session_dir: str, since: float = 0.0)
688
691
  last = EMPTY_REPLY
689
692
 
690
693
  # 去掉文件里的 file:/// 链接(避免把内部路径直出给微信用户)
694
+ last = re.sub(
695
+ r"\[((?:[^\[\]]|\[[^\[\]]*\])+)\]\(file:///(?:[^()\s]|\([^()\s]*\))+\)",
696
+ r"[\1]",
697
+ last,
698
+ )
691
699
  last = re.sub(
692
700
  r"\[([^\]]+)\]\(file:///[^)]+\)",
693
701
  r"[\1]",
@@ -754,7 +762,7 @@ def _collect_fallback_artifacts(session_dir: str, snapshot_before: dict, t0: flo
754
762
  elif fmtime <= prev:
755
763
  continue
756
764
  name = os.path.basename(fp)
757
- if name in _internal_names or name.startswith(".initialized."):
765
+ if name in _internal_names or name.startswith(".initialized.") or is_sensitive_artifact_path(fp, session_dir):
758
766
  continue
759
767
  key = (name, fp)
760
768
  if key not in fallback_seen:
@@ -835,8 +843,9 @@ async def run_codex(prompt: str, user_id: str, timeout: int = None) -> tuple:
835
843
  # 验证并规范化 add_dirs,确保命令构建与 fallback 扫描使用同一批目录
836
844
  validated_add_dirs = _resolve_add_dirs(prefs.get("add_dirs"), session_dir, user_id)
837
845
  prefs["add_dirs"] = validated_add_dirs
846
+ safe_prompt = _sanitize_prompt_at_paths(prompt, session_dir, extra_roots=validated_add_dirs)
838
847
  # persona 经 AGENTS.md 由 codex 自动读取,这里无需注入 argv
839
- cmd = _build_codex_command(prompt, prefs, first, thread_id)
848
+ cmd = _build_codex_command(safe_prompt, prefs, first, thread_id)
840
849
 
841
850
  # 执行前快照现有文件,用于 shell 产物 fallback 扫描(仅 session_dir)
842
851
  snapshot_before = _snapshot_regular_files([session_dir])
@@ -922,7 +931,7 @@ async def run_codex(prompt: str, user_id: str, timeout: int = None) -> tuple:
922
931
  )
923
932
  clear_initialized(session_dir, backend="codex")
924
933
  _delete_codex_thread_id(session_dir)
925
- retry_cmd = _build_codex_command(prompt, prefs, True, "")
934
+ retry_cmd = _build_codex_command(safe_prompt, prefs, True, "")
926
935
  retry_process = await asyncio.create_subprocess_exec(
927
936
  *retry_cmd,
928
937
  stdout=asyncio.subprocess.PIPE,
@@ -1349,13 +1358,26 @@ async def handle_codex_slash_command(text: str, user_id: str) -> str | None:
1349
1358
  session_dir = get_session_dir(user_id)
1350
1359
  clear_initialized(session_dir, backend="codex")
1351
1360
  _delete_codex_thread_id(session_dir)
1361
+ prefs = load_prefs(user_id)
1362
+ if prefs.get("mode"):
1363
+ update_active_prefs(user_id, mode="")
1352
1364
  return "✅ **对话已重置** ✅"
1353
1365
 
1354
1366
  if cmd == "/fast":
1367
+ arg = args.strip().lower()
1368
+ prefs = load_prefs(user_id)
1369
+ if arg in ("off", "0", "false", "no", "normal", "default", "reset", "clear") or (not arg and prefs.get("effort") == "low"):
1370
+ update_active_prefs(user_id, effort="")
1371
+ return "✅ **已关闭快速模式** ✅(恢复默认思考强度)"
1355
1372
  update_active_prefs(user_id, effort="low")
1356
1373
  return "✅ **已开启快速模式** ✅"
1357
1374
 
1358
1375
  if cmd == "/planning":
1376
+ arg = args.strip().lower()
1377
+ prefs = load_prefs(user_id)
1378
+ if arg in ("off", "0", "false", "no", "normal", "default", "reset", "clear", "agent") or (not arg and prefs.get("mode") == "plan"):
1379
+ update_active_prefs(user_id, mode="")
1380
+ return "✅ **已退出规划模式** ✅"
1359
1381
  update_active_prefs(user_id, mode="plan")
1360
1382
  return "✅ **已开启规划模式** ✅(只读沙箱,近似 plan mode)"
1361
1383
 
@@ -1376,6 +1398,8 @@ async def handle_codex_slash_command(text: str, user_id: str) -> str | None:
1376
1398
  prefs = load_prefs(user_id)
1377
1399
  dirs = prefs.get("add_dirs", [])
1378
1400
  if resolved not in dirs:
1401
+ if len(dirs) >= MAX_ADD_DIRS:
1402
+ return f"❌ **附加目录数量已达上限 ({MAX_ADD_DIRS})** ❌"
1379
1403
  dirs.append(resolved)
1380
1404
  prefs["add_dirs"] = dirs
1381
1405
  save_prefs(user_id, prefs)