wechatbridge-cli 1.6.2__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.6.2 → wechatbridge_cli-1.7.1}/PKG-INFO +1 -1
  2. {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/tests/test_dsh.py +2 -2
  3. {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/tests/test_hardening.py +256 -0
  4. {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge/__init__.py +1 -1
  5. {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge/agy.py +115 -27
  6. {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge/codex.py +28 -4
  7. {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge/dsh.py +13 -70
  8. {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge/grok.py +28 -3
  9. {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge/ilink.py +40 -6
  10. {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge/main.py +135 -17
  11. wechatbridge_cli-1.7.1/wechatbridge/price.py +444 -0
  12. {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge/runner_common.py +264 -2
  13. {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge_cli.egg-info/PKG-INFO +1 -1
  14. {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge_cli.egg-info/SOURCES.txt +1 -0
  15. {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/LICENSE +0 -0
  16. {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/README.md +0 -0
  17. {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/pyproject.toml +0 -0
  18. {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/setup.cfg +0 -0
  19. {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/tests/test_codex.py +0 -0
  20. {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/tests/test_codex_auth.py +0 -0
  21. {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/tests/test_grok_auth.py +0 -0
  22. {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge/__main__.py +0 -0
  23. {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge/config.py +0 -0
  24. {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge/update_check.py +0 -0
  25. {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
  26. {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
  27. {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge_cli.egg-info/requires.txt +0 -0
  28. {wechatbridge_cli-1.6.2 → 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.6.2
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
@@ -514,7 +514,7 @@ class TestRunDshIntegration(unittest.IsolatedAsyncioTestCase, _DshIntegrationBas
514
514
 
515
515
  async def test_empty_reply(self):
516
516
  display, artifacts = await self._run("hi", mode="empty")
517
- self.assertEqual(display, "(这次没有文字回复)")
517
+ self.assertEqual(display, "🔔 对话额度已用尽\n\n后端没有返回任何内容,通常是本账号的对话额度/用量已耗尽。请稍后再试或联系管理员确认额度。")
518
518
  self.assertEqual(artifacts, [])
519
519
 
520
520
  async def test_nonzero_exit_maps_to_error_bubble(self):
@@ -1210,7 +1210,7 @@ class TestRunDshMemoryIntegration(unittest.IsolatedAsyncioTestCase, _DshIntegrat
1210
1210
  display, artifacts = await self._run("继续", user_id=user_id, mode="empty")
1211
1211
  self.assertIn("上下文安全提示", display)
1212
1212
  self.assertIn("历史对话记录触发安全策略", display)
1213
- self.assertIn("(这次没有文字回复)", display)
1213
+ self.assertIn("对话额度已用尽", display)
1214
1214
 
1215
1215
  async def test_run_dsh_shows_notice_on_error_reply_when_context_dropped(self):
1216
1216
  """When context was dropped and command exits nonzero / fails, safety notice is preserved separately from error bubble."""
@@ -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.6.2"
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)