wechatbridge-cli 1.4.5__tar.gz → 1.4.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {wechatbridge_cli-1.4.5/wechatbridge_cli.egg-info → wechatbridge_cli-1.4.7}/PKG-INFO +6 -1
  2. {wechatbridge_cli-1.4.5 → wechatbridge_cli-1.4.7}/README.md +5 -0
  3. {wechatbridge_cli-1.4.5 → wechatbridge_cli-1.4.7}/tests/test_hardening.py +537 -0
  4. {wechatbridge_cli-1.4.5 → wechatbridge_cli-1.4.7}/wechatbridge/__init__.py +1 -1
  5. {wechatbridge_cli-1.4.5 → wechatbridge_cli-1.4.7}/wechatbridge/grok.py +182 -25
  6. {wechatbridge_cli-1.4.5 → wechatbridge_cli-1.4.7}/wechatbridge/runner_common.py +180 -66
  7. {wechatbridge_cli-1.4.5 → wechatbridge_cli-1.4.7/wechatbridge_cli.egg-info}/PKG-INFO +6 -1
  8. {wechatbridge_cli-1.4.5 → wechatbridge_cli-1.4.7}/LICENSE +0 -0
  9. {wechatbridge_cli-1.4.5 → wechatbridge_cli-1.4.7}/pyproject.toml +0 -0
  10. {wechatbridge_cli-1.4.5 → wechatbridge_cli-1.4.7}/setup.cfg +0 -0
  11. {wechatbridge_cli-1.4.5 → wechatbridge_cli-1.4.7}/tests/test_codex.py +0 -0
  12. {wechatbridge_cli-1.4.5 → wechatbridge_cli-1.4.7}/wechatbridge/__main__.py +0 -0
  13. {wechatbridge_cli-1.4.5 → wechatbridge_cli-1.4.7}/wechatbridge/agy.py +0 -0
  14. {wechatbridge_cli-1.4.5 → wechatbridge_cli-1.4.7}/wechatbridge/codex.py +0 -0
  15. {wechatbridge_cli-1.4.5 → wechatbridge_cli-1.4.7}/wechatbridge/config.py +0 -0
  16. {wechatbridge_cli-1.4.5 → wechatbridge_cli-1.4.7}/wechatbridge/ilink.py +0 -0
  17. {wechatbridge_cli-1.4.5 → wechatbridge_cli-1.4.7}/wechatbridge/main.py +0 -0
  18. {wechatbridge_cli-1.4.5 → wechatbridge_cli-1.4.7}/wechatbridge/update_check.py +0 -0
  19. {wechatbridge_cli-1.4.5 → wechatbridge_cli-1.4.7}/wechatbridge_cli.egg-info/SOURCES.txt +0 -0
  20. {wechatbridge_cli-1.4.5 → wechatbridge_cli-1.4.7}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
  21. {wechatbridge_cli-1.4.5 → wechatbridge_cli-1.4.7}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
  22. {wechatbridge_cli-1.4.5 → wechatbridge_cli-1.4.7}/wechatbridge_cli.egg-info/requires.txt +0 -0
  23. {wechatbridge_cli-1.4.5 → wechatbridge_cli-1.4.7}/wechatbridge_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wechatbridge-cli
3
- Version: 1.4.5
3
+ Version: 1.4.7
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
@@ -75,6 +75,11 @@ Default data paths expand from `~` (e.g. `~/.local/share/wechatbridge/<instance>
75
75
 
76
76
  Per-user switch: `/backend agy`, `/backend grok`, or `/backend codex`. Each backend keeps its own model / effort / mode memory and persona file layout. Global default is `WECHATBRIDGE_BACKEND`.
77
77
 
78
+ ### Grok backend notes
79
+
80
+ - Isolation: each WeChat user runs with `HOME` pointed at their own session directory. Conversation state stays there; login is machine-wide.
81
+ - Auth: the session links to the host `~/.grok/auth.json` (copied as a fallback), reusing the host `grok login`. Alternatively, set `XAI_API_KEY` in the bridge process environment (the grok child is given that key after env sanitizing). A grok-remote TUI session being signed in is not the same as the host CLI login. No key or token values are stored in this repository.
82
+
78
83
  ### Codex backend notes
79
84
 
80
85
  - Runs `codex exec --json` for each single turn; conversation continuation uses `codex exec resume <thread_id> <prompt>` with the thread id persisted per user.
@@ -44,6 +44,11 @@ Default data paths expand from `~` (e.g. `~/.local/share/wechatbridge/<instance>
44
44
 
45
45
  Per-user switch: `/backend agy`, `/backend grok`, or `/backend codex`. Each backend keeps its own model / effort / mode memory and persona file layout. Global default is `WECHATBRIDGE_BACKEND`.
46
46
 
47
+ ### Grok backend notes
48
+
49
+ - Isolation: each WeChat user runs with `HOME` pointed at their own session directory. Conversation state stays there; login is machine-wide.
50
+ - Auth: the session links to the host `~/.grok/auth.json` (copied as a fallback), reusing the host `grok login`. Alternatively, set `XAI_API_KEY` in the bridge process environment (the grok child is given that key after env sanitizing). A grok-remote TUI session being signed in is not the same as the host CLI login. No key or token values are stored in this repository.
51
+
47
52
  ### Codex backend notes
48
53
 
49
54
  - Runs `codex exec --json` for each single turn; conversation continuation uses `codex exec resume <thread_id> <prompt>` with the thread id persisted per user.
@@ -649,6 +649,96 @@ class TestGrokRelativePathArtifacts(unittest.TestCase):
649
649
  self.assertEqual(path, os.path.abspath(abs_file))
650
650
  self.assertTrue(os.path.isabs(path))
651
651
 
652
+ def test_search_replace_path_key(self):
653
+ from wechatbridge.grok import _extract_grok_artifacts
654
+ import urllib.parse
655
+
656
+ with tempfile.TemporaryDirectory() as session_dir:
657
+ abs_file = os.path.join(session_dir, "report.docx")
658
+ with open(abs_file, "w", encoding="utf-8") as f:
659
+ f.write("doc")
660
+ session_id = "sess-sr-1"
661
+ hist_dir = os.path.join(
662
+ session_dir,
663
+ ".grok",
664
+ "sessions",
665
+ urllib.parse.quote(session_dir, safe=""),
666
+ session_id,
667
+ )
668
+ os.makedirs(hist_dir)
669
+ line = {
670
+ "type": "assistant",
671
+ "tool_calls": [
672
+ {
673
+ "name": "search_replace",
674
+ "arguments": {"path": "report.docx"},
675
+ }
676
+ ],
677
+ }
678
+ with open(os.path.join(hist_dir, "chat_history.jsonl"), "w", encoding="utf-8") as f:
679
+ f.write(json.dumps(line) + "\n")
680
+ arts = _extract_grok_artifacts(session_dir, session_id, since=0.0)
681
+ self.assertEqual(arts, [("report.docx", os.path.abspath(abs_file))])
682
+
683
+
684
+ class TestGrokSessionScanArtifacts(unittest.TestCase):
685
+ def test_scan_picks_new_pdf_skips_bundled(self):
686
+ from wechatbridge.grok import _scan_grok_session_artifacts, _merge_grok_artifacts
687
+
688
+ with tempfile.TemporaryDirectory() as session_dir:
689
+ pdf = os.path.join(session_dir, "out.pdf")
690
+ with open(pdf, "wb") as f:
691
+ f.write(b"%PDF-1.4")
692
+ bundled_dir = os.path.join(session_dir, ".grok", "bundled", "skills", "pdf")
693
+ os.makedirs(bundled_dir)
694
+ bundled = os.path.join(bundled_dir, "form.pdf")
695
+ with open(bundled, "wb") as f:
696
+ f.write(b"%PDF-1.4")
697
+ old = os.path.join(session_dir, "old.docx")
698
+ with open(old, "wb") as f:
699
+ f.write(b"PK")
700
+ os.utime(old, (1_000_000, 1_000_000))
701
+ arts = _scan_grok_session_artifacts(session_dir, since=time.time())
702
+ paths = {p for _, p in arts}
703
+ self.assertIn(os.path.realpath(pdf), paths)
704
+ self.assertNotIn(os.path.realpath(bundled), paths)
705
+ self.assertNotIn(os.path.realpath(old), paths)
706
+
707
+ def test_merge_dedupes_by_realpath(self):
708
+ from wechatbridge.grok import _merge_grok_artifacts
709
+
710
+ with tempfile.TemporaryDirectory() as td:
711
+ p = os.path.join(td, "a.pdf")
712
+ with open(p, "wb") as f:
713
+ f.write(b"x")
714
+ merged = _merge_grok_artifacts(
715
+ [("a.pdf", p)],
716
+ [("a.pdf", os.path.realpath(p))],
717
+ )
718
+ self.assertEqual(merged, [("a.pdf", p)])
719
+
720
+
721
+ class TestGrokAuthPassthrough(unittest.TestCase):
722
+ def test_apply_reinjects_xai_key(self):
723
+ from wechatbridge.grok import _apply_grok_runtime_env, _grok_has_credentials
724
+ from wechatbridge.runner_common import sanitize_env
725
+
726
+ with tempfile.TemporaryDirectory() as td:
727
+ with mock.patch.dict(os.environ, {"XAI_API_KEY": "secret-xai"}, clear=False):
728
+ env = sanitize_env(td)
729
+ self.assertNotIn("XAI_API_KEY", env)
730
+ env = _apply_grok_runtime_env(env)
731
+ self.assertEqual(env["XAI_API_KEY"], "secret-xai")
732
+ self.assertTrue(_grok_has_credentials())
733
+
734
+ def test_has_credentials_false_without_auth_or_key(self):
735
+ from wechatbridge.grok import _grok_has_credentials
736
+
737
+ with mock.patch("wechatbridge.grok._host_grok_dir", return_value="/no/such/grok-home"):
738
+ with mock.patch.dict(os.environ, {"XAI_API_KEY": ""}, clear=False):
739
+ os.environ.pop("XAI_API_KEY", None)
740
+ self.assertFalse(_grok_has_credentials())
741
+
652
742
 
653
743
  class TestILinkDeliveryAccepted(unittest.TestCase):
654
744
  def test_predicate(self):
@@ -1890,3 +1980,450 @@ class TestPreflightBeforeSlot(unittest.IsolatedAsyncioTestCase):
1890
1980
 
1891
1981
  if __name__ == "__main__":
1892
1982
  unittest.main()
1983
+
1984
+
1985
+
1986
+ class TestClassifyCliError(unittest.TestCase):
1987
+ """_classify_cli_error returns correct category for each error pattern."""
1988
+
1989
+ def _cat(self, raw: str, backend: str = "agy") -> str:
1990
+ from wechatbridge.runner_common import _classify_cli_error
1991
+ return _classify_cli_error(raw, backend=backend)
1992
+
1993
+ def _fmt(self, raw: str, backend: str = "agy") -> str:
1994
+ from wechatbridge.runner_common import format_cli_error
1995
+ return format_cli_error(raw, backend=backend)
1996
+
1997
+ # --- payload_too_large --------------------------------------------------------
1998
+
1999
+ def test_request_payload_size_exceeds(self):
2000
+ self.assertEqual(self._cat("request payload size exceeds the limit"), "payload_too_large")
2001
+
2002
+ def test_payload_too_large(self):
2003
+ self.assertEqual(self._cat("payload too large"), "payload_too_large")
2004
+
2005
+ def test_request_entity_too_large(self):
2006
+ self.assertEqual(self._cat("Request Entity Too Large"), "payload_too_large")
2007
+
2008
+ def test_content_length_exceeds(self):
2009
+ self.assertEqual(self._cat("Content-Length exceeds the limit of 8MB"), "payload_too_large")
2010
+
2011
+ def test_content_length_is_too_large(self):
2012
+ self.assertEqual(self._cat("content length is too large"), "payload_too_large")
2013
+
2014
+ def test_http_status_413_explicit(self):
2015
+ self.assertEqual(self._cat("HTTP status 413"), "payload_too_large")
2016
+ self.assertEqual(self._cat("status code 413"), "payload_too_large")
2017
+ self.assertEqual(self._cat("http code 413"), "payload_too_large")
2018
+ self.assertEqual(self._cat("code=413"), "payload_too_large")
2019
+
2020
+ def test_json_code_413(self):
2021
+ self.assertEqual(self._cat('{"code": 413, "message": "too big"}'), "payload_too_large")
2022
+
2023
+ def test_413_in_date_not_matched(self):
2024
+ """A bare 413 in a date string must NOT be classified as payload_too_large."""
2025
+ self.assertNotEqual(self._cat("Session expired on 2024-04-13 at 10:00"), "payload_too_large")
2026
+
2027
+ def test_413_in_path_not_matched(self):
2028
+ """A bare 413 in a file path or directory name must NOT match."""
2029
+ self.assertNotEqual(self._cat("file not found: /tmp/record-413.txt"), "payload_too_large")
2030
+
2031
+ def test_413_in_version_not_matched(self):
2032
+ """Version numbers containing 413 must NOT match."""
2033
+ self.assertNotEqual(self._cat("library version 1.413.0"), "payload_too_large")
2034
+
2035
+ # --- context_too_large --------------------------------------------------------
2036
+
2037
+ def test_input_token_count_exceeds(self):
2038
+ self.assertEqual(self._cat("input token count exceeds the maximum"), "context_too_large")
2039
+
2040
+ def test_context_length_exceeded(self):
2041
+ self.assertEqual(self._cat("context length exceeded"), "context_too_large")
2042
+
2043
+ def test_maximum_context_length(self):
2044
+ self.assertEqual(self._cat("maximum context length is 128k tokens"), "context_too_large")
2045
+
2046
+ def test_context_window_exceeded(self):
2047
+ self.assertEqual(self._cat("context window exceeded"), "context_too_large")
2048
+
2049
+ def test_too_many_tokens(self):
2050
+ self.assertEqual(self._cat("too many tokens in this conversation"), "context_too_large")
2051
+
2052
+ def test_your_input_context_is_too_long(self):
2053
+ self.assertEqual(self._cat("your input context is too long for this model"), "context_too_large")
2054
+
2055
+ def test_invalid_argument_with_context(self):
2056
+ self.assertEqual(self._cat("INVALID_ARGUMENT: context is too long"), "context_too_large")
2057
+
2058
+ def test_resource_exhausted_with_token(self):
2059
+ self.assertEqual(self._cat("RESOURCE_EXHAUSTED: token limit exceeded"), "context_too_large")
2060
+
2061
+ def test_resource_exhausted_with_maximum(self):
2062
+ self.assertEqual(self._cat("resource_exhausted: maximum context size reached"), "context_too_large")
2063
+
2064
+ # --- invalid_argument (generic) -----------------------------------------------
2065
+
2066
+ def test_generic_invalid_argument(self):
2067
+ self.assertEqual(self._cat("INVALID_ARGUMENT: unknown field xyz"), "invalid_argument")
2068
+
2069
+ def test_invalid_argument_no_context_token(self):
2070
+ self.assertEqual(self._cat("invalid_argument: bad request format"), "invalid_argument")
2071
+
2072
+ # --- auth ---------------------------------------------------------------------
2073
+
2074
+ def test_auth_generic(self):
2075
+ self.assertEqual(self._cat("Not signed in. Please run login --device"), "auth")
2076
+
2077
+ def test_auth_unauthorized(self):
2078
+ self.assertEqual(self._cat("unauthorized access"), "auth")
2079
+
2080
+ def test_auth_401_with_token(self):
2081
+ self.assertEqual(self._cat("401 error: token expired"), "auth")
2082
+
2083
+ def test_auth_codex_specific(self):
2084
+ self.assertEqual(self._cat("codex login required", backend="codex"), "auth")
2085
+
2086
+ def test_auth_codex_api_key(self):
2087
+ self.assertEqual(self._cat("codex_api_key missing", backend="codex"), "auth")
2088
+
2089
+ # --- rate_limit ---------------------------------------------------------------
2090
+
2091
+ def test_eligibility_resource_exhausted(self):
2092
+ self.assertEqual(self._cat("Eligibility check failed: RESOURCE_EXHAUSTED"), "resource_exhausted")
2093
+
2094
+ def test_resource_exhausted_generic(self):
2095
+ self.assertEqual(self._cat("RESOURCE_EXHAUSTED: resource has been exhausted"), "resource_exhausted")
2096
+
2097
+ def test_rate_limit_exceeded(self):
2098
+ self.assertEqual(self._cat("rate limit exceeded, please slow down"), "rate_limit")
2099
+
2100
+ def test_too_many_requests(self):
2101
+ self.assertEqual(self._cat("HTTP 429 Too Many Requests"), "rate_limit")
2102
+
2103
+ def test_bare_429(self):
2104
+ self.assertEqual(self._cat("upstream returned status 429"), "bare_429")
2105
+
2106
+ # --- quota --------------------------------------------------------------------
2107
+
2108
+ def test_quota_exceeded(self):
2109
+ self.assertEqual(self._cat("You exceeded your current quota"), "quota")
2110
+
2111
+ def test_daily_quota(self):
2112
+ self.assertEqual(self._cat("daily quota exceeded"), "quota")
2113
+
2114
+ def test_usage_limit(self):
2115
+ self.assertEqual(self._cat("usage limit reached"), "quota")
2116
+
2117
+ # --- network ------------------------------------------------------------------
2118
+
2119
+ def test_connection_refused(self):
2120
+ self.assertEqual(self._cat("connection refused"), "network")
2121
+
2122
+ def test_connection_reset(self):
2123
+ self.assertEqual(self._cat("connection reset by peer"), "network")
2124
+
2125
+ def test_network_unreachable(self):
2126
+ self.assertEqual(self._cat("network is unreachable"), "network")
2127
+
2128
+ # --- cascade_timeout ----------------------------------------------------------
2129
+
2130
+ def test_cascade_timeout(self):
2131
+ self.assertEqual(self._cat("timeout waiting for cascade"), "cascade_timeout")
2132
+
2133
+ def test_cascade_response_timeout(self):
2134
+ self.assertEqual(self._cat("timeout waiting for response"), "cascade_timeout")
2135
+
2136
+ # --- timeout ------------------------------------------------------------------
2137
+
2138
+ def test_generic_timeout(self):
2139
+ self.assertEqual(self._cat("timeout occurred"), "timeout")
2140
+
2141
+ def test_timed_out(self):
2142
+ self.assertEqual(self._cat("operation timed out"), "timeout")
2143
+
2144
+ def test_deadline_exceeded(self):
2145
+ self.assertEqual(self._cat("deadline exceeded"), "timeout")
2146
+
2147
+ # --- permission ---------------------------------------------------------------
2148
+
2149
+ def test_permission_denied(self):
2150
+ self.assertEqual(self._cat("permission denied"), "permission")
2151
+
2152
+ # --- session_not_found --------------------------------------------------------
2153
+
2154
+ def test_no_session_found(self):
2155
+ self.assertEqual(self._cat("no session found"), "session_not_found")
2156
+
2157
+ # --- model_invalid ------------------------------------------------------------
2158
+
2159
+ def test_model_not_found(self):
2160
+ self.assertEqual(self._cat("model not found"), "model_invalid")
2161
+
2162
+ def test_model_unknown(self):
2163
+ self.assertEqual(self._cat("unknown model xyz"), "model_invalid")
2164
+
2165
+ # --- command_not_found --------------------------------------------------------
2166
+
2167
+ def test_command_not_found(self):
2168
+ self.assertEqual(self._cat("command not found: xyz"), "command_not_found")
2169
+
2170
+ # --- not_found ----------------------------------------------------------------
2171
+
2172
+ def test_not_found_generic(self):
2173
+ self.assertEqual(self._cat("not found"), "not_found")
2174
+
2175
+ def test_enoent(self):
2176
+ self.assertEqual(self._cat("enoent: no such file"), "not_found")
2177
+
2178
+ # --- unknown ------------------------------------------------------------------
2179
+
2180
+ def test_empty_is_unknown(self):
2181
+ self.assertEqual(self._cat(""), "unknown")
2182
+
2183
+ def test_garbage_is_unknown(self):
2184
+ self.assertEqual(self._cat("abc123 random noise"), "unknown")
2185
+
2186
+
2187
+ class TestFormatCliErrorCategories(unittest.TestCase):
2188
+ """format_cli_error produces correct user-facing copy for each category."""
2189
+
2190
+ def _fmt(self, raw: str, backend: str = "agy") -> str:
2191
+ from wechatbridge.runner_common import format_cli_error
2192
+ return format_cli_error(raw, backend=backend)
2193
+
2194
+ def test_payload_too_large_title(self):
2195
+ out = self._fmt("request payload size exceeds the limit")
2196
+ self.assertIn("请求内容过大", out)
2197
+ self.assertIn("❌", out)
2198
+ self.assertIn("/new", out)
2199
+
2200
+ def test_context_too_large_title(self):
2201
+ out = self._fmt("context length exceeded")
2202
+ self.assertIn("会话内容过长", out)
2203
+ self.assertIn("❌", out)
2204
+ self.assertIn("/new", out)
2205
+
2206
+ def test_invalid_argument_title(self):
2207
+ out = self._fmt("INVALID_ARGUMENT: bad field")
2208
+ self.assertIn("请求参数无效", out)
2209
+ self.assertIn("❌", out)
2210
+
2211
+ def test_rate_limit_still_notice(self):
2212
+ out = self._fmt("rate limit exceeded")
2213
+ self.assertIn("🔔", out)
2214
+ self.assertIn("请求较多", out)
2215
+
2216
+ def test_quota_still_notice(self):
2217
+ out = self._fmt("quota exceeded")
2218
+ self.assertIn("🔔", out)
2219
+ self.assertIn("额度相关", out)
2220
+
2221
+ def test_auth_still_error(self):
2222
+ out = self._fmt("not signed in")
2223
+ self.assertIn("❌", out)
2224
+ self.assertIn("未登录", out)
2225
+
2226
+ def test_network_still_error(self):
2227
+ out = self._fmt("connection refused")
2228
+ self.assertIn("网络错误", out)
2229
+
2230
+ def test_timeout_still_error(self):
2231
+ out = self._fmt("timeout")
2232
+ self.assertIn("超时", out)
2233
+
2234
+ def test_unknown_still_generic(self):
2235
+ out = self._fmt("something weird happened")
2236
+ self.assertIn("执行失败", out)
2237
+
2238
+
2239
+ class TestFormatCliErrorNoRawInReply(unittest.TestCase):
2240
+ """format_cli_error must never echo raw English text to WeChat users."""
2241
+
2242
+ def _fmt(self, raw: str) -> str:
2243
+ from wechatbridge.runner_common import format_cli_error
2244
+ return format_cli_error(raw, backend="agy")
2245
+
2246
+ def test_no_raw_payload_error(self):
2247
+ out = self._fmt("request payload size exceeds the limit")
2248
+ self.assertNotIn("payload", out.lower())
2249
+ self.assertNotIn("request", out.lower())
2250
+
2251
+ def test_no_raw_context_error(self):
2252
+ out = self._fmt("context length exceeded")
2253
+ self.assertNotIn("context", out.lower())
2254
+ self.assertNotIn("exceeded", out.lower())
2255
+
2256
+ def test_no_raw_invalid_argument(self):
2257
+ out = self._fmt("INVALID_ARGUMENT: bad field")
2258
+ self.assertNotIn("INVALID_ARGUMENT", out)
2259
+
2260
+ def test_no_raw_rate_limit(self):
2261
+ out = self._fmt("rate limit exceeded")
2262
+ self.assertNotIn("rate", out.lower())
2263
+ self.assertNotIn("limit", out.lower())
2264
+
2265
+
2266
+ class TestFormatCliErrorLogging(unittest.TestCase):
2267
+ """format_cli_error logs category, not raw text."""
2268
+
2269
+ def test_log_contains_category_not_raw(self):
2270
+ from wechatbridge.runner_common import format_cli_error, _classify_cli_error
2271
+ import logging
2272
+ import io
2273
+
2274
+ # First, verify the category is correct
2275
+ category = _classify_cli_error("request payload size exceeds the limit")
2276
+ self.assertEqual(category, "payload_too_large")
2277
+
2278
+ # Capture log output — set logger level to INFO so our info() call propagates
2279
+ log_capture = io.StringIO()
2280
+ handler = logging.StreamHandler(log_capture)
2281
+ handler.setLevel(logging.DEBUG)
2282
+ logger = logging.getLogger("wechatbridge.runner")
2283
+ old_level = logger.level
2284
+ logger.setLevel(logging.DEBUG)
2285
+ logger.addHandler(handler)
2286
+ try:
2287
+ format_cli_error("request payload size exceeds the limit", backend="agy")
2288
+ finally:
2289
+ logger.removeHandler(handler)
2290
+ logger.setLevel(old_level)
2291
+
2292
+ log_output = log_capture.getvalue()
2293
+ self.assertIn("category=payload_too_large", log_output)
2294
+ # Verify raw text is NOT in the log
2295
+ self.assertNotIn("request payload", log_output)
2296
+
2297
+ def test_category_independent_of_raw(self):
2298
+ """_classify_cli_error is a pure function; verify it directly."""
2299
+ from wechatbridge.runner_common import _classify_cli_error
2300
+
2301
+ # payload_too_large
2302
+ self.assertEqual(_classify_cli_error("request payload size exceeds the limit"), "payload_too_large")
2303
+ self.assertEqual(_classify_cli_error("payload too large"), "payload_too_large")
2304
+ # context_too_large
2305
+ self.assertEqual(_classify_cli_error("context length exceeded"), "context_too_large")
2306
+ self.assertEqual(_classify_cli_error("too many tokens"), "context_too_large")
2307
+ # invalid_argument
2308
+ self.assertEqual(_classify_cli_error("INVALID_ARGUMENT: bad field"), "invalid_argument")
2309
+ # rate_limit (resource_exhausted without context/token keyword)
2310
+ self.assertEqual(_classify_cli_error("RESOURCE_EXHAUSTED: quota"), "resource_exhausted")
2311
+ # rate_limit (explicit)
2312
+ self.assertEqual(_classify_cli_error("rate limit exceeded"), "rate_limit")
2313
+ # bare 429
2314
+ self.assertEqual(_classify_cli_error("status 429"), "bare_429")
2315
+ # quota
2316
+ self.assertEqual(_classify_cli_error("quota exceeded"), "quota")
2317
+
2318
+
2319
+ class TestFormatCliError413Boundary(unittest.TestCase):
2320
+ """413 must not be misclassified when appearing in dates, paths, or versions."""
2321
+
2322
+ def _cat(self, raw: str) -> str:
2323
+ from wechatbridge.runner_common import _classify_cli_error
2324
+ return _classify_cli_error(raw)
2325
+
2326
+ def test_date_with_413(self):
2327
+ self.assertNotEqual(self._cat("2024-04-13T10:00:00"), "payload_too_large")
2328
+
2329
+ def test_path_with_413(self):
2330
+ self.assertNotEqual(self._cat("/tmp/file-413.txt"), "payload_too_large")
2331
+
2332
+ def test_version_with_413(self):
2333
+ self.assertNotEqual(self._cat("version 1.413.2"), "payload_too_large")
2334
+
2335
+ def test_error_code_413_without_prefix(self):
2336
+ """A bare '413' without status/code/http prefix should NOT match."""
2337
+ self.assertNotEqual(self._cat("error 413 occurred"), "payload_too_large")
2338
+
2339
+ def test_explicit_status_413(self):
2340
+ self.assertEqual(self._cat("status 413"), "payload_too_large")
2341
+
2342
+ def test_explicit_code_413(self):
2343
+ self.assertEqual(self._cat("code: 413"), "payload_too_large")
2344
+
2345
+
2346
+ class TestFormatCliErrorContextResourceExhausted(unittest.TestCase):
2347
+ """RESOURCE_EXHAUSTED with context/token keywords must classify as context_too_large."""
2348
+
2349
+ def _cat(self, raw: str) -> str:
2350
+ from wechatbridge.runner_common import _classify_cli_error
2351
+ return _classify_cli_error(raw)
2352
+
2353
+ def test_resource_exhausted_with_context(self):
2354
+ self.assertEqual(self._cat("RESOURCE_EXHAUSTED: context length exceeded"), "context_too_large")
2355
+
2356
+ def test_resource_exhausted_with_token(self):
2357
+ self.assertEqual(self._cat("RESOURCE_EXHAUSTED: token limit reached"), "context_too_large")
2358
+
2359
+ def test_resource_exhausted_with_maximum(self):
2360
+ self.assertEqual(self._cat("RESOURCE_EXHAUSTED: maximum context size"), "context_too_large")
2361
+
2362
+ def test_invalid_argument_with_context(self):
2363
+ self.assertEqual(self._cat("INVALID_ARGUMENT: context too long"), "context_too_large")
2364
+
2365
+
2366
+ class TestFormatCliErrorPreservesExistingThrottle(unittest.TestCase):
2367
+ """Existing throttle/quota behavior must not be regressed."""
2368
+
2369
+ def _fmt(self, raw: str) -> str:
2370
+ from wechatbridge.runner_common import format_cli_error
2371
+ return format_cli_error(raw, backend="agy")
2372
+
2373
+ def test_eligibility_still_busy(self):
2374
+ out = self._fmt("Eligibility check failed: RESOURCE_EXHAUSTED (code 429)")
2375
+ self.assertIn("助手通道繁忙", out)
2376
+ self.assertIn("🔔", out)
2377
+
2378
+ def test_quota_exceeded_still_quota(self):
2379
+ out = self._fmt("You exceeded your current quota")
2380
+ self.assertIn("额度相关", out)
2381
+ self.assertIn("🔔", out)
2382
+
2383
+ def test_rate_limit_still_busy(self):
2384
+ out = self._fmt("rate limit exceeded")
2385
+ self.assertIn("请求较多", out)
2386
+ self.assertIn("🔔", out)
2387
+
2388
+ def test_bare_429_still_busy(self):
2389
+ out = self._fmt("upstream returned status 429")
2390
+ self.assertIn("助手通道繁忙", out)
2391
+ self.assertIn("🔔", out)
2392
+
2393
+
2394
+ class TestClearInitializedPreservesHistory(unittest.TestCase):
2395
+ """clear_initialized must only remove .initialized flags, not conversation history."""
2396
+
2397
+ def test_clear_initialized_only_removes_flags(self):
2398
+ from wechatbridge.runner_common import clear_initialized, ensure_session_dir
2399
+
2400
+ with tempfile.TemporaryDirectory() as td:
2401
+ sd = os.path.join(td, "user_test")
2402
+ os.makedirs(sd, exist_ok=True)
2403
+ # Create .initialized flag
2404
+ flag = os.path.join(sd, ".initialized.agy")
2405
+ with open(flag, "w") as f:
2406
+ f.write("1")
2407
+ # Create some history files
2408
+ history_dir = os.path.join(sd, ".gemini", "antigravity-cli", "conversations")
2409
+ os.makedirs(history_dir, exist_ok=True)
2410
+ history_file = os.path.join(history_dir, "conv.db")
2411
+ with open(history_file, "w") as f:
2412
+ f.write("history data")
2413
+ # Create prefs
2414
+ prefs_file = os.path.join(sd, "prefs.json")
2415
+ with open(prefs_file, "w") as f:
2416
+ f.write("{}")
2417
+
2418
+ clear_initialized(sd, backend="agy")
2419
+
2420
+ # Flag is removed
2421
+ self.assertFalse(os.path.exists(flag))
2422
+ # History is preserved
2423
+ self.assertTrue(os.path.exists(history_file))
2424
+ # Prefs are preserved
2425
+ self.assertTrue(os.path.exists(prefs_file))
2426
+
2427
+
2428
+ if __name__ == "__main__":
2429
+ unittest.main()
@@ -1,2 +1,2 @@
1
1
  """WeChatBridge — bridge WeChat messages to agy, Grok Build, or Codex CLIs."""
2
- __version__ = "1.4.5"
2
+ __version__ = "1.4.7"
@@ -19,6 +19,7 @@ from .runner_common import (
19
19
  clean_output, load_prefs, save_prefs, is_dangerous, parse_model_effort,
20
20
  sanitize_env, terminate_process, update_active_prefs,
21
21
  format_error, format_cli_error, is_bridge_formatted_reply, EMPTY_REPLY, validate_add_dir,
22
+ path_is_under,
22
23
  )
23
24
 
24
25
  logger = logging.getLogger("grok_runner")
@@ -26,6 +27,37 @@ logger = logging.getLogger("grok_runner")
26
27
  # execve 单参数上限(Linux MAX_ARG_STRLEN = 128KB),留安全余量
27
28
  _MAX_ARG_BYTES = 120 * 1024
28
29
 
30
+ # grok CLI 写文件工具名(新旧混用)。只用这些调用的 path 抽产物会漏掉
31
+ # 经 run_terminal_command 生成的 pdf/docx,所以后面还有目录扫描兜底。
32
+ _GROK_WRITE_TOOLS = frozenset({
33
+ "write",
34
+ "edit",
35
+ "str_replace",
36
+ "search_replace",
37
+ "Write",
38
+ "Edit",
39
+ "StrReplace",
40
+ "SearchReplace",
41
+ })
42
+ _GROK_PATH_KEYS = ("file_path", "path", "target_file")
43
+ _GROK_PASSTHROUGH_ENV = ("XAI_API_KEY",)
44
+ _GROK_SKIP_DIR_NAMES = frozenset({
45
+ ".grok",
46
+ ".gemini",
47
+ ".codex",
48
+ ".git",
49
+ "__pycache__",
50
+ "node_modules",
51
+ "venv",
52
+ ".venv",
53
+ "cache",
54
+ ".cache",
55
+ })
56
+ _GROK_SKIP_FILE_NAMES = frozenset({
57
+ "prefs.json",
58
+ "grok_persona.txt",
59
+ })
60
+
29
61
 
30
62
  # ---------------------------------------------------------------------------
31
63
  # Per-user .grok directory setup
@@ -108,6 +140,28 @@ def ensure_user_grok(user_id: str) -> str:
108
140
  return session_dir
109
141
 
110
142
 
143
+ def _grok_has_credentials() -> bool:
144
+ """True if the grok child can authenticate: host auth.json or XAI_API_KEY."""
145
+ if os.path.isfile(os.path.join(_host_grok_dir(), "auth.json")):
146
+ return True
147
+ key = os.environ.get("XAI_API_KEY")
148
+ return bool(key and str(key).strip())
149
+
150
+
151
+ def _apply_grok_runtime_env(env: dict) -> dict:
152
+ """Re-inject grok auth env after sanitize_env (which strips *API_KEY).
153
+
154
+ Host `grok login` uses ~/.grok/auth.json (symlinked into the session).
155
+ Headless alternative is XAI_API_KEY on the bridge process; without this
156
+ passthrough the child always sees "Not signed in".
157
+ """
158
+ for key in _GROK_PASSTHROUGH_ENV:
159
+ val = os.environ.get(key)
160
+ if val:
161
+ env[key] = val
162
+ return env
163
+
164
+
111
165
  # ---------------------------------------------------------------------------
112
166
  # Persona persistence (via --rules injection)
113
167
  # ---------------------------------------------------------------------------
@@ -280,11 +334,43 @@ def _has_grok_session(session_dir: str) -> bool:
280
334
  # Artifact extraction from chat_history.jsonl
281
335
  # ---------------------------------------------------------------------------
282
336
 
337
+ def _grok_tool_path(args) -> str:
338
+ """Path from a grok write/edit tool argument object."""
339
+ if not isinstance(args, dict):
340
+ return ""
341
+ for key in _GROK_PATH_KEYS:
342
+ val = args.get(key)
343
+ if isinstance(val, str) and val.strip():
344
+ return val.strip()
345
+ return ""
346
+
347
+
348
+ def _resolve_grok_artifact_path(fp: str, session_dir: str, since: float, seen: set, artifacts: list) -> None:
349
+ """Resolve one candidate path and append if it is a new file from this turn."""
350
+ if not fp:
351
+ return
352
+ if not os.path.isabs(fp):
353
+ fp = os.path.join(session_dir, fp)
354
+ try:
355
+ fp = os.path.abspath(fp)
356
+ except (OSError, ValueError):
357
+ return
358
+ try:
359
+ if since and os.path.getmtime(fp) < since - 2.0:
360
+ return
361
+ except OSError:
362
+ return
363
+ key = (os.path.basename(fp), fp)
364
+ if key not in seen:
365
+ seen.add(key)
366
+ artifacts.append(key)
367
+
368
+
283
369
  def _extract_grok_artifacts(session_dir: str, session_id: str, since: float = 0.0) -> list:
284
370
  """Extract (name, abs_path) tuples from grok session chat_history.jsonl.
285
371
 
286
372
  grok stores sessions under $HOME/.grok/sessions/<url-encoded-cwd>/<session-id>/.
287
- The chat_history.jsonl contains structured tool_calls with file_path arguments
373
+ The chat_history.jsonl contains structured tool_calls with path arguments
288
374
  from write/edit operations.
289
375
 
290
376
  ``since``: 只收录 mtime >= since 的文件——chat_history.jsonl 跨轮累积,
@@ -315,6 +401,8 @@ def _extract_grok_artifacts(session_dir: str, session_id: str, since: float = 0.
315
401
  continue
316
402
  if d.get("type") == "assistant" and d.get("tool_calls"):
317
403
  for tc in d.get("tool_calls", []):
404
+ if not isinstance(tc, dict):
405
+ continue
318
406
  name = tc.get("name", "")
319
407
  args = tc.get("arguments", "")
320
408
  if isinstance(args, str):
@@ -322,28 +410,11 @@ def _extract_grok_artifacts(session_dir: str, session_id: str, since: float = 0.
322
410
  args = json.loads(args)
323
411
  except json.JSONDecodeError:
324
412
  continue
325
- if name in ("write", "edit", "str_replace") and isinstance(args, dict):
326
- fp = args.get("file_path", "")
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)
413
+ if name not in _GROK_WRITE_TOOLS:
414
+ continue
415
+ _resolve_grok_artifact_path(
416
+ _grok_tool_path(args), session_dir, since, seen, artifacts
417
+ )
347
418
  except OSError as e:
348
419
  logger.warning("Failed to read chat_history.jsonl: %s", e)
349
420
 
@@ -352,6 +423,78 @@ def _extract_grok_artifacts(session_dir: str, session_id: str, since: float = 0.
352
423
  return artifacts
353
424
 
354
425
 
426
+ def _scan_grok_session_artifacts(session_dir: str, since: float = 0.0) -> list:
427
+ """Collect regular files written under session_dir during this turn.
428
+
429
+ grok often creates pdf/docx via run_terminal_command, which never appears
430
+ in write/edit tool_calls. Skip .grok / .gemini / .codex and other internal
431
+ trees so bundled skill PDFs are not sent back.
432
+
433
+ Bounded like the codex fallback: empty result if the walk exceeds
434
+ 200 files, 50 directories, or 2 seconds.
435
+ """
436
+ if not since or not session_dir or not os.path.isdir(session_dir):
437
+ return []
438
+
439
+ artifacts = []
440
+ seen = set()
441
+ cutoff = since - 2.0
442
+ t_start = time.monotonic()
443
+ file_count = 0
444
+ dir_count = 0
445
+ max_files = 200
446
+ max_dirs = 50
447
+ max_scan_time = 2.0
448
+
449
+ for dirpath, dirnames, filenames in os.walk(session_dir, followlinks=False):
450
+ dir_count += 1
451
+ if dir_count > max_dirs or time.monotonic() - t_start > max_scan_time:
452
+ return []
453
+ dirnames[:] = [d for d in dirnames if d not in _GROK_SKIP_DIR_NAMES]
454
+ for fn in filenames:
455
+ file_count += 1
456
+ if file_count > max_files:
457
+ return []
458
+ if fn in _GROK_SKIP_FILE_NAMES or fn.startswith(".initialized."):
459
+ continue
460
+ fp = os.path.join(dirpath, fn)
461
+ try:
462
+ if os.path.islink(fp) or not os.path.isfile(fp):
463
+ continue
464
+ if os.path.getmtime(fp) < cutoff:
465
+ continue
466
+ if not path_is_under(fp, session_dir):
467
+ continue
468
+ real = os.path.realpath(fp)
469
+ except OSError:
470
+ continue
471
+ key = (os.path.basename(real), real)
472
+ if key not in seen:
473
+ seen.add(key)
474
+ artifacts.append(key)
475
+ return artifacts
476
+
477
+
478
+ def _merge_grok_artifacts(*groups) -> list:
479
+ """Dedupe (name, path) tuples by realpath, preserve first-seen order."""
480
+ merged = []
481
+ seen = set()
482
+ for group in groups:
483
+ for item in group or []:
484
+ if not isinstance(item, (tuple, list)) or len(item) != 2:
485
+ continue
486
+ name, path = item
487
+ try:
488
+ key = os.path.realpath(path)
489
+ except OSError:
490
+ key = path
491
+ if key in seen:
492
+ continue
493
+ seen.add(key)
494
+ merged.append((name, path))
495
+ return merged
496
+
497
+
355
498
  def _parse_grok_output(stdout_text: str, session_dir: str, since: float = 0.0) -> tuple:
356
499
  """Parse grok JSON output into (display_text, artifacts).
357
500
 
@@ -378,6 +521,10 @@ def _parse_grok_output(stdout_text: str, session_dir: str, since: float = 0.0) -
378
521
  artifacts = []
379
522
  if session_id:
380
523
  artifacts = _extract_grok_artifacts(session_dir, session_id, since=since)
524
+ # Merge directory scan so pdf/docx created via shell still go back.
525
+ artifacts = _merge_grok_artifacts(
526
+ artifacts, _scan_grok_session_artifacts(session_dir, since)
527
+ )
381
528
 
382
529
  # Strip file:/// links from display (in case grok emits them)
383
530
  display = re.sub(
@@ -413,6 +560,16 @@ async def run_grok(prompt: str, user_id: str, timeout: int = None) -> tuple:
413
560
  t0 = time.time()
414
561
  session_dir = ensure_user_grok(user_id)
415
562
 
563
+ if not _grok_has_credentials():
564
+ logger.warning(
565
+ "grok credentials missing: no host auth.json and no XAI_API_KEY"
566
+ )
567
+ return format_cli_error(
568
+ "Not signed in. To authenticate without a browser, run:\n"
569
+ " grok login --device-code",
570
+ backend="grok",
571
+ ), []
572
+
416
573
  # Audit logging
417
574
  logger.info("[AUDIT] user=%s prompt=%.200s", user_id, prompt)
418
575
  if is_dangerous(prompt):
@@ -441,7 +598,7 @@ async def run_grok(prompt: str, user_id: str, timeout: int = None) -> tuple:
441
598
 
442
599
  process = None
443
600
  try:
444
- env = sanitize_env(session_dir)
601
+ env = _apply_grok_runtime_env(sanitize_env(session_dir))
445
602
  env["PAGER"] = "cat"
446
603
  env["CI"] = "true"
447
604
  env["NONINTERACTIVE"] = "1"
@@ -581,7 +738,7 @@ async def _run_grok_subcommand(subcmd_args: list, user_id: str) -> str:
581
738
  cmd = [config.grok_binary_path] + subcmd_args
582
739
  process = None
583
740
  try:
584
- env = sanitize_env(session_dir)
741
+ env = _apply_grok_runtime_env(sanitize_env(session_dir))
585
742
  process = await asyncio.create_subprocess_exec(
586
743
  *cmd,
587
744
  stdout=asyncio.subprocess.PIPE,
@@ -283,25 +283,65 @@ def format_artifact_send_failure_notice(art_name: str, reason: str) -> str:
283
283
  )
284
284
 
285
285
 
286
- def format_cli_error(raw_message: str, *, backend: str = "") -> str:
287
- """Map backend stderr/JSON error text into a short Chinese user reply.
288
-
289
- Never put English raw blobs or internal path/env names into user text.
290
- Details stay in the server log only.
286
+ def _classify_cli_error(raw_message: str, *, backend: str = "") -> str:
287
+ """Classify a backend error string into a category label.
288
+
289
+ Returns one of:
290
+ payload_too_large, context_too_large, invalid_argument,
291
+ auth, resource_exhausted, rate_limit, bare_429, quota,
292
+ network, timeout, cascade_timeout, permission,
293
+ session_not_found, model_invalid, command_not_found, not_found,
294
+ unknown
295
+
296
+ This is a pure function (no I/O, no logging). ``format_cli_error``
297
+ drives user-facing copy from the category; callers that need the
298
+ category directly (e.g. structured logging) can use this too.
291
299
  """
292
- raw = clean_output(raw_message or "") or "未知错误"
300
+ raw = clean_output(raw_message or "") or ""
293
301
  lower = raw.lower()
294
302
  backend = (backend or "").strip().lower()
295
- logger.info(
296
- "format_cli_error backend=%s raw=%.300s",
297
- backend or "?",
298
- raw,
299
- )
300
303
 
301
- # codex-specific auth/login recognition (backend-scoped, precise).
302
- # Only explicit login semantics match, so ordinary API errors, rate
303
- # limits and model errors are never misclassified as a login problem.
304
- # agy/grok keep using the generic block below (results unchanged).
304
+ # --- 1. Payload / request body too large (413) --------------------------------
305
+ # Match explicit payload/body-size phrases first to avoid
306
+ # catching a bare 413 that appears in dates or path segments.
307
+ _PAYLOAD_SIZE_RE = re.compile(
308
+ r"request\s+payload\s+size\s+exceeds\s+the\s+limit"
309
+ r"|payload\s+too\s+large"
310
+ r"|request\s+entity\s+too\s+large"
311
+ r"|content[-\s]?length\s+(?:exceeds|exceeded|is\s+too\s+large)",
312
+ re.IGNORECASE,
313
+ )
314
+ if _PAYLOAD_SIZE_RE.search(lower):
315
+ return "payload_too_large"
316
+ # HTTP 413 with surrounding context (status/code/http prefix or JSON \"code\":413)
317
+ if re.search(
318
+ r"(?:status|code|http)\s*[:=]?\s*413\b", lower
319
+ ) or re.search(r'\"code\"\s*:\s*413', lower):
320
+ return "payload_too_large"
321
+
322
+ # --- 2. Context / token limit -------------------------------------------------
323
+ _CONTEXT_TOKEN_RE = re.compile(
324
+ r"input\s+token\s+count\s+exceeds\s+the\s+maximum"
325
+ r"|context\s+length\s+exceeded"
326
+ r"|maximum\s+context\s+length"
327
+ r"|context\s+window\s+exceeded"
328
+ r"|too\s+many\s+tokens"
329
+ r"|your\s+input\s+context\s+is\s+too\s+long",
330
+ re.IGNORECASE,
331
+ )
332
+ if _CONTEXT_TOKEN_RE.search(lower):
333
+ return "context_too_large"
334
+ # INVALID_ARGUMENT or RESOURCE_EXHAUSTED combined with context/token max signals
335
+ _ISAE = re.compile(r"invalid_argument|resource_exhausted", re.IGNORECASE)
336
+ _CTX_KW = re.compile(r"context|token|maximum", re.IGNORECASE)
337
+ if _ISAE.search(lower) and _CTX_KW.search(lower):
338
+ return "context_too_large"
339
+
340
+ # --- 3. Generic INVALID_ARGUMENT (not payload/context) ------------------------
341
+ if "invalid_argument" in lower:
342
+ return "invalid_argument"
343
+
344
+ # --- 4. Auth / login ----------------------------------------------------------
305
345
  if backend == "codex":
306
346
  if (
307
347
  "codex login" in lower
@@ -326,12 +366,8 @@ def format_cli_error(raw_message: str, *, backend: str = "") -> str:
326
366
  or "unauthorized" in lower
327
367
  or "401" in lower and ("auth" in lower or "token" in lower or "login" in lower)
328
368
  ):
329
- return format_error(
330
- "未登录",
331
- "助手尚未登录或凭证失效,请联系管理员处理。",
332
- )
369
+ return "auth"
333
370
 
334
- # Auth / login — ops details stay in logs; users contact admin
335
371
  if (
336
372
  "not signed in" in lower
337
373
  or "authenticate" in lower
@@ -345,28 +381,18 @@ def format_cli_error(raw_message: str, *, backend: str = "") -> str:
345
381
  or ("xai_api_key" in lower and ("sign" in lower or "login" in lower or "auth" in lower))
346
382
  or "api_key" in lower and ("missing" in lower or "invalid" in lower or "required" in lower)
347
383
  ):
348
- return format_error(
349
- "未登录",
350
- "助手尚未登录或凭证失效,请联系管理员处理。",
351
- )
384
+ return "auth"
352
385
 
353
- # Upstream eligibility / control-plane RESOURCE_EXHAUSTED (short-window throttle).
354
- # Typical agy stderr: "Eligibility check failed: RESOURCE_EXHAUSTED (code 429):
355
- # Resource has been exhausted (e.g. check quota)." — not daily-quota zero,
356
- # not bridge concurrency, not user spam. More specific patterns first.
386
+ # --- 5. Rate limit / quota (RESOURCE_EXHAUSTED without context/token) ---------
387
+ # Must come AFTER context_too_large so "context/token + RESOURCE_EXHAUSTED"
388
+ # does not land here.
357
389
  if (
358
390
  "eligibility" in lower
359
391
  or "resource_exhausted" in lower
360
392
  or "resource exhausted" in lower
361
393
  ):
362
- return format_notice(
363
- "助手通道繁忙",
364
- "上游助手通道暂时限流或繁忙,请稍等片刻再试。",
365
- )
394
+ return "resource_exhausted"
366
395
 
367
- # Explicit account / daily quota (after resource-exhausted so "check quota"
368
- # in that message does not steal this branch).
369
- # Require exceed/limit/daily/… semantics — bare "quota usage report" must not match.
370
396
  if (
371
397
  "quota exceeded" in lower
372
398
  or "quota_exceeded" in lower
@@ -384,33 +410,22 @@ def format_cli_error(raw_message: str, *, backend: str = "") -> str:
384
410
  )
385
411
  )
386
412
  ):
387
- return format_notice(
388
- "额度相关",
389
- "当前额度或配额可能受限,请稍后再试或联系管理员。",
390
- )
413
+ return "quota"
391
414
 
392
- # Explicit rate limit / too many requests (no resource-exhausted wording).
393
415
  if (
394
416
  "rate limit" in lower
395
417
  or "rate_limit" in lower
396
418
  or "too many requests" in lower
397
419
  ):
398
- return format_notice(
399
- "请求较多",
400
- "当前请求较多,请稍后再试。",
401
- )
420
+ return "rate_limit"
402
421
 
403
422
  # Bare 429 fallback — word-boundary so "1429" / "x4290" do not match.
404
- # Also accept explicit "status 429" / "code 429" forms.
405
423
  if re.search(r"\b429\b", lower) or re.search(
406
424
  r"(?:status|code|http)\s*[:=]?\s*429\b", lower
407
425
  ):
408
- return format_notice(
409
- "助手通道繁忙",
410
- "上游暂时限流或繁忙,请稍等片刻再试。",
411
- )
426
+ return "bare_429"
412
427
 
413
- # Network
428
+ # --- 6. Network ---------------------------------------------------------------
414
429
  if (
415
430
  "connection refused" in lower
416
431
  or "connection reset" in lower
@@ -423,26 +438,21 @@ def format_cli_error(raw_message: str, *, backend: str = "") -> str:
423
438
  or "fetch failed" in lower
424
439
  or "socket hang up" in lower
425
440
  ):
426
- return format_error("网络错误", "连不上服务,请检查网络后重试。")
441
+ return "network"
427
442
 
428
- # Cascade / API hang (agy) — plain language for users
443
+ # --- 7. Timeout / cascade -----------------------------------------------------
429
444
  if "timeout waiting for cascade" in lower or "timeout waiting for response" in lower:
430
- return format_error(
431
- "模型响应超时",
432
- "模型响应超时,请稍后重试或简化指令。",
433
- )
434
-
435
- if "permission" in lower and ("denied" in lower or "refuse" in lower or "rejected" in lower):
436
- return format_error("权限不足", "没有执行该操作的权限。")
445
+ return "cascade_timeout"
437
446
 
438
447
  if "timeout" in lower or "timed out" in lower or "deadline exceeded" in lower:
439
- return format_error("超时", "等待响应超时,请稍后重试。")
448
+ return "timeout"
449
+
450
+ # --- 8. Permission / misc specific --------------------------------------------
451
+ if "permission" in lower and ("denied" in lower or "refuse" in lower or "rejected" in lower):
452
+ return "permission"
440
453
 
441
454
  if "no session found" in lower:
442
- return format_error(
443
- "会话不存在",
444
- "上一轮对话记录已过期,请重新发一次消息即可。",
445
- )
455
+ return "session_not_found"
446
456
 
447
457
  if "model" in lower and (
448
458
  "not found" in lower
@@ -453,15 +463,119 @@ def format_cli_error(raw_message: str, *, backend: str = "") -> str:
453
463
  or "not supported" in lower
454
464
  or "no such" in lower
455
465
  ):
456
- return format_error("模型无效", "指定的模型不可用,请用 `/models` 查看后重选。")
466
+ return "model_invalid"
457
467
 
458
468
  if "command not found" in lower or "not a command" in lower:
469
+ return "command_not_found"
470
+
471
+ if "not found" in lower or "no such file" in lower or "enoent" in lower:
472
+ return "not_found"
473
+
474
+ return "unknown"
475
+
476
+
477
+ def format_cli_error(raw_message: str, *, backend: str = "") -> str:
478
+ """Map backend stderr/JSON error text into a short Chinese user reply.
479
+
480
+ Never put English raw blobs or internal path/env names into user text.
481
+ Details stay in the server log only.
482
+ """
483
+ raw = clean_output(raw_message or "") or "未知错误"
484
+ category = _classify_cli_error(raw, backend=backend)
485
+ logger.info(
486
+ "format_cli_error backend=%s category=%s",
487
+ backend or "?",
488
+ category,
489
+ )
490
+
491
+ # --- category → user-facing copy -----------------------------------------------
492
+ if category == "auth":
493
+ return format_error(
494
+ "未登录",
495
+ "助手尚未登录或凭证失效,请联系管理员处理。",
496
+ )
497
+
498
+ if category == "payload_too_large":
499
+ return format_error(
500
+ "请求内容过大",
501
+ (
502
+ "本次发送的内容超出服务端限制。\n"
503
+ "如果已续聊很久,请发 /new 开始新会话;\n"
504
+ "若新会话仍失败,请减少本次文字、图片或文件。"
505
+ ),
506
+ )
507
+
508
+ if category == "context_too_large":
509
+ return format_error(
510
+ "会话内容过长",
511
+ (
512
+ "会话累积内容超出模型上下文限制。\n"
513
+ "请发 /new 开始新会话后重试。"
514
+ ),
515
+ )
516
+
517
+ if category == "invalid_argument":
518
+ return format_error(
519
+ "请求参数无效",
520
+ "本次输入参数有误,请检查内容后重试。",
521
+ )
522
+
523
+ # --- throttle / quota (🔔 notice style — existing behaviour preserved) ---------
524
+ if category == "resource_exhausted":
525
+ return format_notice(
526
+ "助手通道繁忙",
527
+ "上游助手通道暂时限流或繁忙,请稍等片刻再试。",
528
+ )
529
+
530
+ if category == "rate_limit":
531
+ return format_notice(
532
+ "请求较多",
533
+ "当前请求较多,请稍后再试。",
534
+ )
535
+
536
+ if category == "bare_429":
537
+ return format_notice(
538
+ "助手通道繁忙",
539
+ "上游暂时限流或繁忙,请稍等片刻再试。",
540
+ )
541
+
542
+ if category == "quota":
543
+ return format_notice(
544
+ "额度相关",
545
+ "当前额度或配额可能受限,请稍后再试或联系管理员。",
546
+ )
547
+
548
+ if category == "network":
549
+ return format_error("网络错误", "连不上服务,请检查网络后重试。")
550
+
551
+ if category == "cascade_timeout":
552
+ return format_error(
553
+ "模型响应超时",
554
+ "模型响应超时,请稍后重试或简化指令。",
555
+ )
556
+
557
+ if category == "permission":
558
+ return format_error("权限不足", "没有执行该操作的权限。")
559
+
560
+ if category == "timeout":
561
+ return format_error("超时", "等待响应超时,请稍后重试。")
562
+
563
+ if category == "session_not_found":
564
+ return format_error(
565
+ "会话不存在",
566
+ "上一轮对话记录已过期,请重新发一次消息即可。",
567
+ )
568
+
569
+ if category == "model_invalid":
570
+ return format_error("模型无效", "指定的模型不可用,请用 `/models` 查看后重选。")
571
+
572
+ if category == "command_not_found":
459
573
  return format_error(
460
574
  "助手不可用",
461
575
  "助手程序未正确安装或配置,请联系管理员。",
462
576
  )
463
577
 
464
- if "not found" in lower or "no such file" in lower or "enoent" in lower:
578
+ if category == "not_found":
465
579
  return format_error("未找到", "请求的资源或文件不存在。")
466
580
 
467
581
  # Unknown: fixed Chinese only — never echo English raw to WeChat users
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wechatbridge-cli
3
- Version: 1.4.5
3
+ Version: 1.4.7
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
@@ -75,6 +75,11 @@ Default data paths expand from `~` (e.g. `~/.local/share/wechatbridge/<instance>
75
75
 
76
76
  Per-user switch: `/backend agy`, `/backend grok`, or `/backend codex`. Each backend keeps its own model / effort / mode memory and persona file layout. Global default is `WECHATBRIDGE_BACKEND`.
77
77
 
78
+ ### Grok backend notes
79
+
80
+ - Isolation: each WeChat user runs with `HOME` pointed at their own session directory. Conversation state stays there; login is machine-wide.
81
+ - Auth: the session links to the host `~/.grok/auth.json` (copied as a fallback), reusing the host `grok login`. Alternatively, set `XAI_API_KEY` in the bridge process environment (the grok child is given that key after env sanitizing). A grok-remote TUI session being signed in is not the same as the host CLI login. No key or token values are stored in this repository.
82
+
78
83
  ### Codex backend notes
79
84
 
80
85
  - Runs `codex exec --json` for each single turn; conversation continuation uses `codex exec resume <thread_id> <prompt>` with the thread id persisted per user.