wechatbridge-cli 1.4.7__tar.gz → 1.4.9__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 (25) hide show
  1. {wechatbridge_cli-1.4.7/wechatbridge_cli.egg-info → wechatbridge_cli-1.4.9}/PKG-INFO +3 -3
  2. {wechatbridge_cli-1.4.7 → wechatbridge_cli-1.4.9}/README.md +2 -2
  3. {wechatbridge_cli-1.4.7 → wechatbridge_cli-1.4.9}/tests/test_codex.py +28 -0
  4. wechatbridge_cli-1.4.9/tests/test_codex_auth.py +266 -0
  5. wechatbridge_cli-1.4.9/tests/test_grok_auth.py +238 -0
  6. {wechatbridge_cli-1.4.7 → wechatbridge_cli-1.4.9}/wechatbridge/__init__.py +1 -1
  7. {wechatbridge_cli-1.4.7 → wechatbridge_cli-1.4.9}/wechatbridge/codex.py +174 -25
  8. {wechatbridge_cli-1.4.7 → wechatbridge_cli-1.4.9}/wechatbridge/grok.py +115 -6
  9. {wechatbridge_cli-1.4.7 → wechatbridge_cli-1.4.9/wechatbridge_cli.egg-info}/PKG-INFO +3 -3
  10. {wechatbridge_cli-1.4.7 → wechatbridge_cli-1.4.9}/wechatbridge_cli.egg-info/SOURCES.txt +2 -0
  11. {wechatbridge_cli-1.4.7 → wechatbridge_cli-1.4.9}/LICENSE +0 -0
  12. {wechatbridge_cli-1.4.7 → wechatbridge_cli-1.4.9}/pyproject.toml +0 -0
  13. {wechatbridge_cli-1.4.7 → wechatbridge_cli-1.4.9}/setup.cfg +0 -0
  14. {wechatbridge_cli-1.4.7 → wechatbridge_cli-1.4.9}/tests/test_hardening.py +0 -0
  15. {wechatbridge_cli-1.4.7 → wechatbridge_cli-1.4.9}/wechatbridge/__main__.py +0 -0
  16. {wechatbridge_cli-1.4.7 → wechatbridge_cli-1.4.9}/wechatbridge/agy.py +0 -0
  17. {wechatbridge_cli-1.4.7 → wechatbridge_cli-1.4.9}/wechatbridge/config.py +0 -0
  18. {wechatbridge_cli-1.4.7 → wechatbridge_cli-1.4.9}/wechatbridge/ilink.py +0 -0
  19. {wechatbridge_cli-1.4.7 → wechatbridge_cli-1.4.9}/wechatbridge/main.py +0 -0
  20. {wechatbridge_cli-1.4.7 → wechatbridge_cli-1.4.9}/wechatbridge/runner_common.py +0 -0
  21. {wechatbridge_cli-1.4.7 → wechatbridge_cli-1.4.9}/wechatbridge/update_check.py +0 -0
  22. {wechatbridge_cli-1.4.7 → wechatbridge_cli-1.4.9}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
  23. {wechatbridge_cli-1.4.7 → wechatbridge_cli-1.4.9}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
  24. {wechatbridge_cli-1.4.7 → wechatbridge_cli-1.4.9}/wechatbridge_cli.egg-info/requires.txt +0 -0
  25. {wechatbridge_cli-1.4.7 → wechatbridge_cli-1.4.9}/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.7
3
+ Version: 1.4.9
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
@@ -78,13 +78,13 @@ Per-user switch: `/backend agy`, `/backend grok`, or `/backend codex`. Each back
78
78
  ### Grok backend notes
79
79
 
80
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.
81
+ - Auth: the session links to the host `~/.grok/auth.json` (copied as a fallback), reusing the host `grok login`. The grok CLI rewrites `auth.json` via temp-file + rename, which replaces the session symlink with a regular file; the bridge promotes such session files back to the host (atomic copy) and re-links, so refreshed tokens never get overwritten by the revoked host copy. 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
82
 
83
83
  ### Codex backend notes
84
84
 
85
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.
86
86
  - Isolation: each WeChat user runs with `HOME` and `CODEX_HOME` pointed at their own per-user session directory (`session_dir/.codex`), so sessions, logs, and caches never cross users.
87
- - Auth: the per-user session links to the host `~/.codex/auth.json` (copied as a fallback), reusing the host `codex login`. Alternatively, set `CODEX_API_KEY` in the bridge process environment to authenticate. No key or token values are stored in this repository.
87
+ - Auth: the per-user session links to the host `~/.codex/auth.json` (copied as a fallback), reusing the host `codex login`. The codex CLI rewrites `auth.json` via temp-file + rename, which replaces the session symlink with a regular file; the bridge promotes such session files back to the host (atomic copy) and re-links, so refreshed tokens never get overwritten by the revoked host copy. Alternatively, set `CODEX_API_KEY` in the bridge process environment to authenticate. No key or token values are stored in this repository.
88
88
  - **Status:** there is currently no real Codex subscription or CLI available for live testing. The codex backend is implemented from source research, a JSONL fixture, and a fake CLI used by the test suite (which passes). Final acceptance depends on a real user running it against the actual Codex CLI.
89
89
 
90
90
  ## Prerequisites
@@ -47,13 +47,13 @@ Per-user switch: `/backend agy`, `/backend grok`, or `/backend codex`. Each back
47
47
  ### Grok backend notes
48
48
 
49
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.
50
+ - Auth: the session links to the host `~/.grok/auth.json` (copied as a fallback), reusing the host `grok login`. The grok CLI rewrites `auth.json` via temp-file + rename, which replaces the session symlink with a regular file; the bridge promotes such session files back to the host (atomic copy) and re-links, so refreshed tokens never get overwritten by the revoked host copy. 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
51
 
52
52
  ### Codex backend notes
53
53
 
54
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.
55
55
  - Isolation: each WeChat user runs with `HOME` and `CODEX_HOME` pointed at their own per-user session directory (`session_dir/.codex`), so sessions, logs, and caches never cross users.
56
- - Auth: the per-user session links to the host `~/.codex/auth.json` (copied as a fallback), reusing the host `codex login`. Alternatively, set `CODEX_API_KEY` in the bridge process environment to authenticate. No key or token values are stored in this repository.
56
+ - Auth: the per-user session links to the host `~/.codex/auth.json` (copied as a fallback), reusing the host `codex login`. The codex CLI rewrites `auth.json` via temp-file + rename, which replaces the session symlink with a regular file; the bridge promotes such session files back to the host (atomic copy) and re-links, so refreshed tokens never get overwritten by the revoked host copy. Alternatively, set `CODEX_API_KEY` in the bridge process environment to authenticate. No key or token values are stored in this repository.
57
57
  - **Status:** there is currently no real Codex subscription or CLI available for live testing. The codex backend is implemented from source research, a JSONL fixture, and a fake CLI used by the test suite (which passes). Final acceptance depends on a real user running it against the actual Codex CLI.
58
58
 
59
59
  ## Prerequisites
@@ -935,6 +935,34 @@ class TestCodexRunFakeCli(unittest.IsolatedAsyncioTestCase):
935
935
  result_entries = [a for a in artifacts if a[0] == "result.txt"]
936
936
  self.assertEqual(len(result_entries), 1, "no duplicate from fallback")
937
937
 
938
+ def test_shell_pdf_merged_with_file_change(self):
939
+ """file_change for helper.py plus a shell-written report.pdf — both return."""
940
+ display, artifacts = self._run("hi", "u-shell-mix", mode="ok_shell_mixed_docs")
941
+ self.assertEqual(display, "done")
942
+ names = {n for n, _ in artifacts}
943
+ self.assertIn("helper.py", names)
944
+ self.assertIn("report.pdf", names)
945
+ self.assertEqual(len([a for a in artifacts if a[0] == "helper.py"]), 1)
946
+ self.assertEqual(len([a for a in artifacts if a[0] == "report.pdf"]), 1)
947
+
948
+ def test_shell_overwrite_existing_pdf(self):
949
+ """Rewriting a pre-existing pdf must still collect it."""
950
+ from wechatbridge.runner_common import sanitize_user_id
951
+
952
+ uid = "u-shell-ow"
953
+ sd = os.path.join(self.td, sanitize_user_id(uid))
954
+ os.makedirs(sd)
955
+ pdf = os.path.join(sd, "report.pdf")
956
+ with open(pdf, "wb") as f:
957
+ f.write(b"%PDF-old")
958
+ os.utime(pdf, (1_000_000, 1_000_000))
959
+ display, artifacts = self._run("hi", uid, mode="ok_shell_mixed_docs")
960
+ self.assertEqual(display, "done")
961
+ names = {n for n, _ in artifacts}
962
+ self.assertIn("report.pdf", names)
963
+ with open(pdf, "rb") as f:
964
+ self.assertEqual(f.read(), b"%PDF-1.4")
965
+
938
966
  def test_shell_file_fallback_old_files_not_collected(self):
939
967
  """ok_shell_old_files: files with old mtimes are NOT collected."""
940
968
  display, artifacts = self._run("hi", "u-shell-old", mode="ok_shell_old_files")
@@ -0,0 +1,266 @@
1
+ """Unit tests for wechatbridge.codex auth promote/re-link (symlink credential sync).
2
+
3
+ Mirrors tests/test_grok_auth.py: the CLI rewrites auth.json via
4
+ temp-file + rename, which replaces the session symlink with a regular file.
5
+ The bridge must promote that regular file back to host atomically (creating
6
+ the host dir when missing) and then re-link the session. All tests mock
7
+ _host_codex_dir to a temp dir; the real /root/.codex/auth.json is never
8
+ touched. Fixtures are fake {"probe": ...} JSON — no real tokens.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import shutil
16
+ import tempfile
17
+ import unittest
18
+ from unittest import mock
19
+ from unittest.mock import AsyncMock, MagicMock
20
+
21
+ from wechatbridge.codex import (
22
+ _session_auth_is_regular,
23
+ _sync_codex_auth,
24
+ )
25
+
26
+
27
+ def _write_json(path: str, data) -> None:
28
+ with open(path, "w", encoding="utf-8") as f:
29
+ json.dump(data, f)
30
+
31
+
32
+ def _read_json(path: str) -> dict:
33
+ with open(path, "r", encoding="utf-8") as f:
34
+ return json.load(f)
35
+
36
+
37
+ def _is_symlink_to(path: str, target: str) -> bool:
38
+ return os.path.islink(path) and os.path.realpath(path) == os.path.realpath(target)
39
+
40
+
41
+ class CodexAuthSyncTest(unittest.TestCase):
42
+ """_sync_codex_auth promote / re-link behavior."""
43
+
44
+ def setUp(self):
45
+ self.td = tempfile.mkdtemp(prefix="wb-codex-auth-")
46
+ self.host_dir = os.path.join(self.td, "host_codex")
47
+ os.makedirs(self.host_dir, mode=0o700)
48
+ self.codex_dir = os.path.join(self.td, "session", ".codex")
49
+ os.makedirs(self.codex_dir, mode=0o700)
50
+ self.host_auth = os.path.join(self.host_dir, "auth.json")
51
+ self.dest = os.path.join(self.codex_dir, "auth.json")
52
+ self._host_patcher = mock.patch(
53
+ "wechatbridge.codex._host_codex_dir", return_value=self.host_dir
54
+ )
55
+ self._host_patcher.start()
56
+
57
+ def tearDown(self):
58
+ self._host_patcher.stop()
59
+ shutil.rmtree(self.td, ignore_errors=True)
60
+
61
+ def test_regular_session_file_promotes_to_host_and_relinks(self):
62
+ # session carries NEW credentials as a regular file (CLI rename broke
63
+ # the symlink); host still has OLD credentials
64
+ _write_json(self.dest, {"probe": "new"})
65
+ _write_json(self.host_auth, {"probe": "old"})
66
+
67
+ self.assertTrue(_sync_codex_auth(self.codex_dir))
68
+ self.assertEqual(_read_json(self.host_auth), {"probe": "new"})
69
+ self.assertTrue(_is_symlink_to(self.dest, self.host_auth))
70
+
71
+ def test_cli_rename_over_symlink_promotes_on_next_sync(self):
72
+ # start as a correct symlink
73
+ _write_json(self.host_auth, {"probe": "old"})
74
+ self.assertTrue(_sync_codex_auth(self.codex_dir))
75
+ self.assertTrue(_is_symlink_to(self.dest, self.host_auth))
76
+
77
+ # simulate CLI refresh: temp file + os.replace replaces the symlink
78
+ # with a regular file holding the new credentials
79
+ fd, tmp = tempfile.mkstemp(dir=self.codex_dir, prefix=".auth.json.", suffix=".tmp")
80
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
81
+ json.dump({"probe": "new"}, f)
82
+ os.replace(tmp, self.dest)
83
+ self.assertTrue(_session_auth_is_regular(self.dest))
84
+
85
+ # next message sync must promote and re-link
86
+ self.assertTrue(_sync_codex_auth(self.codex_dir))
87
+ self.assertEqual(_read_json(self.host_auth), {"probe": "new"})
88
+ self.assertTrue(_is_symlink_to(self.dest, self.host_auth))
89
+
90
+ def test_correct_symlink_untouched(self):
91
+ _write_json(self.host_auth, {"probe": "old"})
92
+ self.assertTrue(_sync_codex_auth(self.codex_dir))
93
+ link_target = os.readlink(self.dest)
94
+
95
+ self.assertTrue(_sync_codex_auth(self.codex_dir))
96
+ self.assertEqual(os.readlink(self.dest), link_target)
97
+ self.assertEqual(_read_json(self.host_auth), {"probe": "old"})
98
+
99
+ def test_missing_session_file_creates_symlink(self):
100
+ _write_json(self.host_auth, {"probe": "old"})
101
+ self.assertTrue(_sync_codex_auth(self.codex_dir))
102
+ self.assertTrue(_is_symlink_to(self.dest, self.host_auth))
103
+
104
+ def test_promote_failure_keeps_session_file(self):
105
+ _write_json(self.dest, {"probe": "new"})
106
+ _write_json(self.host_auth, {"probe": "old"})
107
+ with mock.patch(
108
+ "wechatbridge.codex._atomic_copy_auth", return_value=False
109
+ ) as copy_mock:
110
+ self.assertTrue(_sync_codex_auth(self.codex_dir))
111
+
112
+ # session file must NOT be unlinked; host must NOT be overwritten
113
+ self.assertTrue(_session_auth_is_regular(self.dest))
114
+ self.assertEqual(_read_json(self.dest), {"probe": "new"})
115
+ self.assertEqual(_read_json(self.host_auth), {"probe": "old"})
116
+ copy_mock.assert_called_once_with(self.dest, self.host_auth)
117
+
118
+ def test_host_missing_regular_session_promotes_and_creates_host_dir(self):
119
+ # no host dir / auth.json at all; session has refreshed credentials
120
+ shutil.rmtree(self.host_dir)
121
+ _write_json(self.dest, {"probe": "new"})
122
+
123
+ self.assertTrue(_sync_codex_auth(self.codex_dir))
124
+ self.assertEqual(_read_json(self.host_auth), {"probe": "new"})
125
+ self.assertTrue(_is_symlink_to(self.dest, self.host_auth))
126
+
127
+ def test_empty_session_file_not_promoted(self):
128
+ # CLI left a 0-byte / half-written auth.json behind: must not be
129
+ # promoted over host credentials, and the session file stays put
130
+ _write_json(self.host_auth, {"probe": "old"})
131
+ open(self.dest, "wb").close()
132
+ self.assertTrue(_session_auth_is_regular(self.dest))
133
+
134
+ self.assertTrue(_sync_codex_auth(self.codex_dir))
135
+ self.assertEqual(_read_json(self.host_auth), {"probe": "old"})
136
+ self.assertTrue(_session_auth_is_regular(self.dest))
137
+ self.assertEqual(os.path.getsize(self.dest), 0)
138
+
139
+
140
+ class CodexRunPromoteTest(unittest.IsolatedAsyncioTestCase):
141
+ """run_codex / _run_codex_subcommand harvest (promote) after exit."""
142
+
143
+ async def asyncSetUp(self):
144
+ self.td = tempfile.mkdtemp(prefix="wb-codex-run-")
145
+ self.session_dir = os.path.join(self.td, "s1")
146
+ os.makedirs(os.path.join(self.session_dir, ".codex"), mode=0o700)
147
+ self._patchers = [
148
+ mock.patch(
149
+ "wechatbridge.codex.ensure_user_codex",
150
+ return_value=self.session_dir,
151
+ ),
152
+ mock.patch("wechatbridge.codex.is_dangerous", return_value=False),
153
+ mock.patch("wechatbridge.codex.is_first_message", return_value=True),
154
+ mock.patch("wechatbridge.codex.load_prefs", return_value={}),
155
+ mock.patch(
156
+ "wechatbridge.codex._resolve_add_dirs", return_value=[]
157
+ ),
158
+ mock.patch(
159
+ "wechatbridge.codex._build_codex_command",
160
+ return_value=["codex", "exec", "--json", "hi"],
161
+ ),
162
+ mock.patch(
163
+ "wechatbridge.codex._snapshot_regular_files", return_value=[]
164
+ ),
165
+ mock.patch(
166
+ "wechatbridge.codex.sanitize_env",
167
+ side_effect=lambda d: {"HOME": d},
168
+ ),
169
+ mock.patch(
170
+ "wechatbridge.codex._collect_fallback_artifacts",
171
+ return_value=[],
172
+ ),
173
+ mock.patch(
174
+ "wechatbridge.codex._parse_codex_output",
175
+ return_value=("ok", [], None, False),
176
+ ),
177
+ ]
178
+ for p in self._patchers:
179
+ p.start()
180
+
181
+ async def asyncTearDown(self):
182
+ for p in self._patchers:
183
+ p.stop()
184
+ shutil.rmtree(self.td, ignore_errors=True)
185
+
186
+ def _fake_proc(self, returncode: int = 0, out: bytes = b"", err: bytes = b""):
187
+ proc = MagicMock()
188
+ proc.returncode = returncode
189
+ proc.communicate = AsyncMock(return_value=(out, err))
190
+ return proc
191
+
192
+ async def test_run_codex_exit_harvests_and_keeps_session_env(self):
193
+ from wechatbridge import codex as codex_mod
194
+
195
+ proc = self._fake_proc(returncode=0, out=b"ok")
196
+ with mock.patch.object(
197
+ codex_mod.asyncio,
198
+ "create_subprocess_exec",
199
+ AsyncMock(return_value=proc),
200
+ ) as spawn:
201
+ with mock.patch.object(
202
+ codex_mod, "_promote_session_auth", return_value=True
203
+ ) as promote:
204
+ display, artifacts = await codex_mod.run_codex(
205
+ "hi", "u1", timeout=30
206
+ )
207
+
208
+ promote.assert_called_once_with(
209
+ os.path.join(self.session_dir, ".codex")
210
+ )
211
+ self.assertEqual(display, "ok")
212
+ self.assertEqual(artifacts, [])
213
+ env = spawn.call_args.kwargs["env"]
214
+ # HOME still points at the session dir; CODEX_HOME stays session-private
215
+ self.assertEqual(env["HOME"], self.session_dir)
216
+ self.assertEqual(
217
+ env["CODEX_HOME"], os.path.join(self.session_dir, ".codex")
218
+ )
219
+
220
+ async def test_run_codex_nonzero_exit_still_promotes(self):
221
+ from wechatbridge import codex as codex_mod
222
+
223
+ proc = self._fake_proc(returncode=1, err=b"boom")
224
+ with mock.patch.object(
225
+ codex_mod.asyncio,
226
+ "create_subprocess_exec",
227
+ AsyncMock(return_value=proc),
228
+ ):
229
+ with mock.patch.object(
230
+ codex_mod, "_promote_session_auth", return_value=True
231
+ ) as promote:
232
+ display, artifacts = await codex_mod.run_codex(
233
+ "hi", "u1", timeout=30
234
+ )
235
+
236
+ promote.assert_called_once_with(
237
+ os.path.join(self.session_dir, ".codex")
238
+ )
239
+ self.assertEqual(artifacts, [])
240
+ # 非零退出 → 格式化错误气泡("boom" 无已知分类时是通用执行失败)
241
+ self.assertIn("❌", display)
242
+
243
+ async def test_subcommand_exit_harvests(self):
244
+ from wechatbridge import codex as codex_mod
245
+
246
+ proc = self._fake_proc(returncode=0, out=b"models list")
247
+ with mock.patch.object(
248
+ codex_mod.asyncio,
249
+ "create_subprocess_exec",
250
+ AsyncMock(return_value=proc),
251
+ ):
252
+ with mock.patch.object(
253
+ codex_mod, "_promote_session_auth", return_value=True
254
+ ) as promote:
255
+ result = await codex_mod._run_codex_subcommand(
256
+ ["debug", "models"], "u1"
257
+ )
258
+
259
+ promote.assert_called_once_with(
260
+ os.path.join(self.session_dir, ".codex")
261
+ )
262
+ self.assertIn("models", result)
263
+
264
+
265
+ if __name__ == "__main__":
266
+ unittest.main()
@@ -0,0 +1,238 @@
1
+ """Unit tests for wechatbridge.grok auth promote/re-link (symlink credential sync).
2
+
3
+ Covers the credential-refresh bug: the CLI rewrites auth.json via
4
+ temp-file + rename, which replaces the session symlink with a regular file.
5
+ The bridge must promote that regular file back to host atomically and then
6
+ re-link the session. All tests mock _host_grok_dir to a temp dir; the real
7
+ /root/.grok/auth.json is never touched. Fixtures are fake {"probe": ...}
8
+ JSON — no real tokens.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import json
15
+ import os
16
+ import shutil
17
+ import tempfile
18
+ import unittest
19
+ from unittest import mock
20
+ from unittest.mock import AsyncMock, MagicMock
21
+
22
+ from wechatbridge.grok import (
23
+ _session_auth_is_regular,
24
+ _sync_grok_auth,
25
+ )
26
+
27
+
28
+ def _write_json(path: str, data) -> None:
29
+ with open(path, "w", encoding="utf-8") as f:
30
+ json.dump(data, f)
31
+
32
+
33
+ def _read_json(path: str) -> dict:
34
+ with open(path, "r", encoding="utf-8") as f:
35
+ return json.load(f)
36
+
37
+
38
+ def _is_symlink_to(path: str, target: str) -> bool:
39
+ return os.path.islink(path) and os.path.realpath(path) == os.path.realpath(target)
40
+
41
+
42
+ class GrokAuthSyncTest(unittest.TestCase):
43
+ """_sync_grok_auth promote / re-link behavior."""
44
+
45
+ def setUp(self):
46
+ self.td = tempfile.mkdtemp(prefix="wb-grok-auth-")
47
+ self.host_dir = os.path.join(self.td, "host_grok")
48
+ os.makedirs(self.host_dir, mode=0o700)
49
+ self.grok_dir = os.path.join(self.td, "session", ".grok")
50
+ os.makedirs(self.grok_dir, mode=0o700)
51
+ self.host_auth = os.path.join(self.host_dir, "auth.json")
52
+ self.dest = os.path.join(self.grok_dir, "auth.json")
53
+ self._host_patcher = mock.patch(
54
+ "wechatbridge.grok._host_grok_dir", return_value=self.host_dir
55
+ )
56
+ self._host_patcher.start()
57
+
58
+ def tearDown(self):
59
+ self._host_patcher.stop()
60
+ shutil.rmtree(self.td, ignore_errors=True)
61
+
62
+ def test_regular_session_file_promotes_to_host_and_relinks(self):
63
+ # session carries NEW credentials as a regular file (CLI rename broke
64
+ # the symlink); host still has OLD credentials
65
+ _write_json(self.dest, {"probe": "new"})
66
+ _write_json(self.host_auth, {"probe": "old"})
67
+
68
+ self.assertTrue(_sync_grok_auth(self.grok_dir))
69
+ self.assertEqual(_read_json(self.host_auth), {"probe": "new"})
70
+ self.assertTrue(_is_symlink_to(self.dest, self.host_auth))
71
+
72
+ def test_cli_rename_over_symlink_promotes_on_next_sync(self):
73
+ # start as a correct symlink
74
+ _write_json(self.host_auth, {"probe": "old"})
75
+ self.assertTrue(_sync_grok_auth(self.grok_dir))
76
+ self.assertTrue(_is_symlink_to(self.dest, self.host_auth))
77
+
78
+ # simulate CLI refresh: temp file + os.replace replaces the symlink
79
+ # with a regular file holding the new credentials
80
+ fd, tmp = tempfile.mkstemp(dir=self.grok_dir, prefix=".auth.json.", suffix=".tmp")
81
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
82
+ json.dump({"probe": "new"}, f)
83
+ os.replace(tmp, self.dest)
84
+ self.assertTrue(_session_auth_is_regular(self.dest))
85
+
86
+ # next message sync must promote and re-link
87
+ self.assertTrue(_sync_grok_auth(self.grok_dir))
88
+ self.assertEqual(_read_json(self.host_auth), {"probe": "new"})
89
+ self.assertTrue(_is_symlink_to(self.dest, self.host_auth))
90
+
91
+ def test_correct_symlink_untouched(self):
92
+ _write_json(self.host_auth, {"probe": "old"})
93
+ self.assertTrue(_sync_grok_auth(self.grok_dir))
94
+ link_target = os.readlink(self.dest)
95
+
96
+ self.assertTrue(_sync_grok_auth(self.grok_dir))
97
+ self.assertEqual(os.readlink(self.dest), link_target)
98
+ self.assertEqual(_read_json(self.host_auth), {"probe": "old"})
99
+
100
+ def test_missing_session_file_creates_symlink(self):
101
+ _write_json(self.host_auth, {"probe": "old"})
102
+ self.assertTrue(_sync_grok_auth(self.grok_dir))
103
+ self.assertTrue(_is_symlink_to(self.dest, self.host_auth))
104
+
105
+ def test_promote_failure_keeps_session_file(self):
106
+ _write_json(self.dest, {"probe": "new"})
107
+ _write_json(self.host_auth, {"probe": "old"})
108
+ with mock.patch(
109
+ "wechatbridge.grok._atomic_copy_auth", return_value=False
110
+ ) as copy_mock:
111
+ self.assertTrue(_sync_grok_auth(self.grok_dir))
112
+
113
+ # session file must NOT be unlinked; host must NOT be overwritten
114
+ self.assertTrue(_session_auth_is_regular(self.dest))
115
+ self.assertEqual(_read_json(self.dest), {"probe": "new"})
116
+ self.assertEqual(_read_json(self.host_auth), {"probe": "old"})
117
+ copy_mock.assert_called_once_with(self.dest, self.host_auth)
118
+
119
+ def test_host_missing_regular_session_promotes(self):
120
+ # no host auth.json at all; session has refreshed credentials
121
+ _write_json(self.dest, {"probe": "new"})
122
+ self.assertTrue(_sync_grok_auth(self.grok_dir))
123
+ self.assertEqual(_read_json(self.host_auth), {"probe": "new"})
124
+ self.assertTrue(_is_symlink_to(self.dest, self.host_auth))
125
+
126
+ def test_host_dir_missing_regular_session_promotes_and_creates_host_dir(self):
127
+ # host dir removed entirely (never ran `grok login`): promote must
128
+ # create it (0o700) before the atomic copy
129
+ shutil.rmtree(self.host_dir)
130
+ _write_json(self.dest, {"probe": "new"})
131
+
132
+ self.assertTrue(_sync_grok_auth(self.grok_dir))
133
+ self.assertEqual(_read_json(self.host_auth), {"probe": "new"})
134
+ self.assertTrue(_is_symlink_to(self.dest, self.host_auth))
135
+
136
+ def test_empty_session_file_not_promoted(self):
137
+ # CLI left a 0-byte / half-written auth.json behind: must not be
138
+ # promoted over host credentials, and the session file stays put
139
+ _write_json(self.host_auth, {"probe": "old"})
140
+ open(self.dest, "wb").close()
141
+ self.assertTrue(_session_auth_is_regular(self.dest))
142
+
143
+ self.assertTrue(_sync_grok_auth(self.grok_dir))
144
+ self.assertEqual(_read_json(self.host_auth), {"probe": "old"})
145
+ self.assertTrue(_session_auth_is_regular(self.dest))
146
+ self.assertEqual(os.path.getsize(self.dest), 0)
147
+
148
+
149
+ class GrokRunPromoteTest(unittest.IsolatedAsyncioTestCase):
150
+ """run_grok harvests (promotes) after the subprocess exits, even non-zero."""
151
+
152
+ async def asyncSetUp(self):
153
+ self.td = tempfile.mkdtemp(prefix="wb-grok-run-")
154
+ self.session_dir = os.path.join(self.td, "s1")
155
+ os.makedirs(os.path.join(self.session_dir, ".grok"), mode=0o700)
156
+ self._patchers = [
157
+ mock.patch(
158
+ "wechatbridge.grok.ensure_user_grok", return_value=self.session_dir
159
+ ),
160
+ mock.patch("wechatbridge.grok._grok_has_credentials", return_value=True),
161
+ mock.patch("wechatbridge.grok.is_dangerous", return_value=False),
162
+ mock.patch("wechatbridge.grok.is_first_message", return_value=True),
163
+ mock.patch("wechatbridge.grok.load_prefs", return_value={}),
164
+ mock.patch("wechatbridge.grok._read_persona", return_value=None),
165
+ mock.patch(
166
+ "wechatbridge.grok._build_grok_command",
167
+ return_value=["grok", "-p", "hi"],
168
+ ),
169
+ mock.patch(
170
+ "wechatbridge.grok.sanitize_env",
171
+ side_effect=lambda d: {"HOME": d},
172
+ ),
173
+ mock.patch(
174
+ "wechatbridge.grok._apply_grok_runtime_env",
175
+ side_effect=lambda env: dict(env),
176
+ ),
177
+ mock.patch(
178
+ "wechatbridge.grok._parse_grok_output",
179
+ return_value=("oops", []),
180
+ ),
181
+ ]
182
+ for p in self._patchers:
183
+ p.start()
184
+
185
+ async def asyncTearDown(self):
186
+ for p in self._patchers:
187
+ p.stop()
188
+ shutil.rmtree(self.td, ignore_errors=True)
189
+
190
+ async def test_nonzero_exit_still_promotes(self):
191
+ from wechatbridge import grok as grok_mod
192
+
193
+ proc = MagicMock()
194
+ proc.returncode = 1
195
+ proc.communicate = AsyncMock(return_value=(b"", b"boom"))
196
+ with mock.patch.object(
197
+ grok_mod.asyncio,
198
+ "create_subprocess_exec",
199
+ AsyncMock(return_value=proc),
200
+ ):
201
+ with mock.patch.object(
202
+ grok_mod, "_promote_session_auth", return_value=True
203
+ ) as promote:
204
+ display, artifacts = await grok_mod.run_grok(
205
+ "hi", "u1", timeout=30
206
+ )
207
+
208
+ promote.assert_called_once_with(
209
+ os.path.join(self.session_dir, ".grok")
210
+ )
211
+ self.assertEqual(artifacts, [])
212
+ # 非零退出 → 格式化错误气泡("boom" 无已知分类时是通用执行失败)
213
+ self.assertIn("❌", display)
214
+
215
+ async def test_subcommand_exit_harvests(self):
216
+ from wechatbridge import grok as grok_mod
217
+
218
+ proc = MagicMock()
219
+ proc.returncode = 0
220
+ proc.communicate = AsyncMock(return_value=(b"models list", b""))
221
+ with mock.patch.object(
222
+ grok_mod.asyncio,
223
+ "create_subprocess_exec",
224
+ AsyncMock(return_value=proc),
225
+ ):
226
+ with mock.patch.object(
227
+ grok_mod, "_promote_session_auth", return_value=True
228
+ ) as promote:
229
+ result = await grok_mod._run_grok_subcommand(["models"], "u1")
230
+
231
+ promote.assert_called_once_with(
232
+ os.path.join(self.session_dir, ".grok")
233
+ )
234
+ self.assertIn("models", result)
235
+
236
+
237
+ if __name__ == "__main__":
238
+ unittest.main()
@@ -1,2 +1,2 @@
1
1
  """WeChatBridge — bridge WeChat messages to agy, Grok Build, or Codex CLIs."""
2
- __version__ = "1.4.7"
2
+ __version__ = "1.4.9"
@@ -14,6 +14,8 @@ import logging
14
14
  import os
15
15
  import re
16
16
  import shutil
17
+ import stat
18
+ import tempfile
17
19
  import time
18
20
  import uuid
19
21
 
@@ -101,16 +103,117 @@ def _host_codex_dir() -> str:
101
103
  return os.path.join(host_home, ".codex")
102
104
 
103
105
 
106
+ def _session_auth_is_regular(path: str) -> bool:
107
+ """True if path exists as a regular file (lstat, no symlink follow)."""
108
+ try:
109
+ return stat.S_ISREG(os.lstat(path).st_mode)
110
+ except OSError:
111
+ return False
112
+
113
+
114
+ def _atomic_copy_auth(src: str, dst: str) -> bool:
115
+ """Atomically copy src -> dst (same-dir temp file + os.replace).
116
+
117
+ The temp file lives in dirname(dst) to stay on the same filesystem.
118
+ Never logs auth file content — only paths and the failure reason.
119
+ """
120
+ tmp_path = None
121
+ try:
122
+ fd, tmp_path = tempfile.mkstemp(
123
+ dir=os.path.dirname(dst), prefix=".auth.json.", suffix=".tmp"
124
+ )
125
+ with os.fdopen(fd, "wb") as fout:
126
+ with open(src, "rb") as fin:
127
+ shutil.copyfileobj(fin, fout)
128
+ os.chmod(tmp_path, 0o600)
129
+ os.replace(tmp_path, dst)
130
+ tmp_path = None
131
+ return True
132
+ except OSError as e:
133
+ logger.error("Atomic copy auth -> %s failed: %s", dst, e)
134
+ return False
135
+ finally:
136
+ if tmp_path is not None:
137
+ try:
138
+ os.unlink(tmp_path)
139
+ except OSError:
140
+ pass
141
+
142
+
143
+ def _promote_session_auth(codex_dir: str) -> bool:
144
+ """Treat a regular session auth.json as refreshed credentials.
145
+
146
+ The CLI refreshes tokens via temp-file + rename; the rename replaces the
147
+ session symlink with a regular file, so the new credentials (incl. the
148
+ rotated refresh token) exist only in the session while host keeps the
149
+ already-revoked old ones. Promote the session file back to host
150
+ atomically, then re-link the session to the host file. The session
151
+ file is never unlinked when the copy fails or the non-empty
152
+ validation fails; after a successful copy the re-link step does
153
+ unlink the session file and rebuild the symlink (the new credentials
154
+ already live in host at that point, so no data is lost). Creates the
155
+ host dir (0o700) when missing.
156
+ """
157
+ dest = os.path.join(codex_dir, "auth.json")
158
+ if not _session_auth_is_regular(dest):
159
+ return True
160
+ host = os.path.join(_host_codex_dir(), "auth.json")
161
+ host_dir = os.path.dirname(host)
162
+ try:
163
+ if not os.path.isdir(host_dir):
164
+ os.makedirs(host_dir, mode=0o700, exist_ok=True)
165
+ except OSError as e:
166
+ logger.error("Cannot create host codex dir %s: %s", host_dir, e)
167
+ return False
168
+ try:
169
+ if os.path.getsize(dest) <= 0:
170
+ logger.error(
171
+ "Refusing to promote empty session auth %s (host %s untouched)",
172
+ dest,
173
+ host,
174
+ )
175
+ return False
176
+ except OSError as e:
177
+ logger.error("Cannot stat session auth %s: %s", dest, e)
178
+ return False
179
+ if not _atomic_copy_auth(dest, host):
180
+ logger.error(
181
+ "Promote session auth failed; keeping %s (host %s untouched)", dest, host
182
+ )
183
+ return False
184
+ try:
185
+ host_real = os.path.realpath(host)
186
+ os.unlink(dest)
187
+ os.symlink(host_real, dest)
188
+ logger.info("Promoted session auth.json -> %s", host_real)
189
+ return True
190
+ except OSError as e:
191
+ logger.error("Re-link %s after promote failed: %s", dest, e)
192
+ return False
193
+
194
+
104
195
  def _sync_codex_auth(codex_dir: str) -> bool:
105
196
  """Make session .codex/auth.json track the host login credentials.
106
197
 
107
- Strategy: symlink session auth.json -> host auth.json (shared refresh).
108
- Falls back to copy2 if symlink is not possible.
198
+ CLI refresh writes $CODEX_HOME/auth.json via temp-file + rename; the
199
+ rename replaces a session symlink with a regular file, so refreshed
200
+ credentials (incl. the rotated refresh token) land only in the session
201
+ while host keeps the old, already-revoked ones. A regular session
202
+ auth.json is therefore always treated as newer credentials: promote it
203
+ back to host atomically, then re-link the session to the host file. On
204
+ promote failure the session file is never unlinked. Falls back to copy2
205
+ if symlink is not possible.
109
206
  Returns True if credentials are available for the child process.
110
207
  """
111
208
  auth_src = os.path.join(_host_codex_dir(), "auth.json")
112
209
  auth_dst = os.path.join(codex_dir, "auth.json")
113
210
 
211
+ # 普通文件 = CLI rename 拆掉 symlink 后留下的更新凭证,先回流 host
212
+ if _session_auth_is_regular(auth_dst):
213
+ if not _promote_session_auth(codex_dir):
214
+ # promote 失败:session 文件是唯一的新凭证,绝不 unlink
215
+ return os.path.isfile(auth_dst)
216
+
114
217
  if not os.path.isfile(auth_src):
115
218
  logger.warning("Host codex auth missing: %s", auth_src)
116
219
  return False
@@ -633,26 +736,52 @@ def _extract_codex_error_messages(stdout_text: str) -> list:
633
736
 
634
737
 
635
738
  def _collect_fallback_artifacts(session_dir: str, snapshot_before: dict, t0: float) -> list:
636
- """Scan session_dir for new files created during codex execution.
739
+ """Scan session_dir for files created or overwritten during this turn.
637
740
 
638
- Only runs when: returncode==0, parse_failed==False, no structured artifacts.
639
- Scans only session_dir (never add_dirs). Excludes internal metadata dirs.
640
- Each candidate realpath is verified to be under session_dir.
741
+ Codex often emits a ``file_change`` for a helper script and then writes
742
+ the real pdf/docx via the shell. Always merge this list with structured
743
+ artifacts (dedupe by realpath). Scans only session_dir (never add_dirs).
744
+ Excludes internal metadata dirs. Each candidate is verified under session_dir.
641
745
  """
642
746
  _internal_names = {".codex_thread_id"}
643
747
  fallback = []
644
748
  fallback_seen = set()
645
749
  for fp, fmtime in _snapshot_regular_files([session_dir]).items():
646
- if fmtime >= t0 and fp not in snapshot_before:
647
- name = os.path.basename(fp)
648
- if name in _internal_names or name.startswith(".initialized."):
750
+ prev = snapshot_before.get(fp)
751
+ if prev is None:
752
+ if fmtime < t0:
649
753
  continue
650
- key = (name, fp)
651
- if key not in fallback_seen:
652
- fallback_seen.add(key)
653
- fallback.append(key)
754
+ elif fmtime <= prev:
755
+ continue
756
+ name = os.path.basename(fp)
757
+ if name in _internal_names or name.startswith(".initialized."):
758
+ continue
759
+ key = (name, fp)
760
+ if key not in fallback_seen:
761
+ fallback_seen.add(key)
762
+ fallback.append(key)
654
763
  return fallback
655
764
 
765
+
766
+ def _merge_codex_artifacts(*groups) -> list:
767
+ """Dedupe (name, path) tuples by realpath, preserve first-seen order."""
768
+ merged = []
769
+ seen = set()
770
+ for group in groups:
771
+ for item in group or []:
772
+ if not isinstance(item, (tuple, list)) or len(item) != 2:
773
+ continue
774
+ name, path = item
775
+ try:
776
+ key = os.path.realpath(path)
777
+ except OSError:
778
+ key = path
779
+ if key in seen:
780
+ continue
781
+ seen.add(key)
782
+ merged.append((name, path))
783
+ return merged
784
+
656
785
  async def run_codex(prompt: str, user_id: str, timeout: int = None) -> tuple:
657
786
  """Execute codex CLI for a given user message.
658
787
 
@@ -680,6 +809,7 @@ async def run_codex(prompt: str, user_id: str, timeout: int = None) -> tuple:
680
809
 
681
810
  t0 = time.time()
682
811
  session_dir = ensure_user_codex(user_id)
812
+ codex_dir = os.path.join(session_dir, ".codex")
683
813
 
684
814
  # Audit logging
685
815
  logger.info("[AUDIT] user=%s prompt=%.200s", user_id, prompt)
@@ -831,12 +961,18 @@ async def run_codex(prompt: str, user_id: str, timeout: int = None) -> tuple:
831
961
  mark_initialized(session_dir, backend="codex")
832
962
  if r_thread_id:
833
963
  _write_codex_thread_id(session_dir, r_thread_id)
834
- # Fallback: shell 产物扫描(仅重试成功且无结构化 artifacts 时触发)
835
- if not r_failed and not r_artifacts:
836
- fallback = _collect_fallback_artifacts(session_dir, snapshot_before, t0)
837
- if fallback:
838
- r_artifacts = fallback
839
- logger.info("Fallback scan collected %d additional files on retry", len(r_artifacts))
964
+ # Fallback: 重试成功也扫本轮新建/覆盖的文件,与 file_change 合并
965
+ if not r_failed:
966
+ extra = _collect_fallback_artifacts(session_dir, snapshot_before, t0)
967
+ if extra:
968
+ before = len(r_artifacts)
969
+ r_artifacts = _merge_codex_artifacts(r_artifacts, extra)
970
+ added = len(r_artifacts) - before
971
+ if added:
972
+ logger.info(
973
+ "Fallback scan collected %d additional files on retry",
974
+ added,
975
+ )
840
976
  elapsed = time.time() - t0
841
977
  logger.info(
842
978
  "codex retry done: user=%s elapsed=%.1fs artifacts=%d output=%d chars failed=%s",
@@ -850,12 +986,16 @@ async def run_codex(prompt: str, user_id: str, timeout: int = None) -> tuple:
850
986
  if parsed_thread_id:
851
987
  _write_codex_thread_id(session_dir, parsed_thread_id)
852
988
 
853
- # Fallback: shell 产物扫描(仅在成功且无结构化 artifacts 时触发)
854
- if not failed and not artifacts:
855
- fallback = _collect_fallback_artifacts(session_dir, snapshot_before, t0)
856
- if fallback:
857
- artifacts = fallback
858
- logger.info("Fallback scan collected %d additional files", len(artifacts))
989
+ # Fallback: 成功就扫本轮新建/覆盖的文件,与 file_change 合并去重。
990
+ # 不能再要求 artifacts 为空——先写出脚本再终端生成 pdf 时会漏文档。
991
+ if not failed:
992
+ extra = _collect_fallback_artifacts(session_dir, snapshot_before, t0)
993
+ if extra:
994
+ before = len(artifacts)
995
+ artifacts = _merge_codex_artifacts(artifacts, extra)
996
+ added = len(artifacts) - before
997
+ if added:
998
+ logger.info("Fallback scan collected %d additional files", added)
859
999
 
860
1000
  elapsed = time.time() - t0
861
1001
  logger.info(
@@ -882,6 +1022,11 @@ async def run_codex(prompt: str, user_id: str, timeout: int = None) -> tuple:
882
1022
  "这次没处理好,请稍后再试。若一直失败,请联系管理员。",
883
1023
  ), []
884
1024
 
1025
+ finally:
1026
+ # 子进程结束后立刻 harvest:CLI 可能刚用 rename 拆掉 symlink 刷新了
1027
+ # 凭证,普通文件一律回流 host(成功/非零退出/超时/取消均覆盖)
1028
+ _promote_session_auth(codex_dir)
1029
+
885
1030
 
886
1031
  # ---------------------------------------------------------------------------
887
1032
  # Slash command support
@@ -895,6 +1040,7 @@ async def _run_codex_subcommand(subcmd_args: list, user_id: str) -> str:
895
1040
  ``sanitize_env``.
896
1041
  """
897
1042
  session_dir = ensure_user_codex(user_id)
1043
+ codex_dir = os.path.join(session_dir, ".codex")
898
1044
  cmd = [config.codex_binary_path] + list(subcmd_args)
899
1045
  process = None
900
1046
  try:
@@ -950,6 +1096,9 @@ async def _run_codex_subcommand(subcmd_args: list, user_id: str) -> str:
950
1096
  "这次没处理好,请稍后再试。若一直失败,请联系管理员。",
951
1097
  )
952
1098
 
1099
+ finally:
1100
+ _promote_session_auth(codex_dir)
1101
+
953
1102
 
954
1103
  def _parse_codex_models(output: str) -> list[str]:
955
1104
  """Loose-parse model slug list from ``codex debug models`` stdout.
@@ -10,6 +10,8 @@ import logging
10
10
  import os
11
11
  import re
12
12
  import shutil
13
+ import stat
14
+ import tempfile
13
15
  import time
14
16
  import urllib.parse
15
17
 
@@ -73,20 +75,117 @@ def _host_grok_dir() -> str:
73
75
  return os.path.join(host_home, ".grok")
74
76
 
75
77
 
78
+ def _session_auth_is_regular(path: str) -> bool:
79
+ """True if path exists as a regular file (lstat, no symlink follow)."""
80
+ try:
81
+ return stat.S_ISREG(os.lstat(path).st_mode)
82
+ except OSError:
83
+ return False
84
+
85
+
86
+ def _atomic_copy_auth(src: str, dst: str) -> bool:
87
+ """Atomically copy src -> dst (same-dir temp file + os.replace).
88
+
89
+ The temp file lives in dirname(dst) to stay on the same filesystem.
90
+ Never logs auth file content — only paths and the failure reason.
91
+ """
92
+ tmp_path = None
93
+ try:
94
+ fd, tmp_path = tempfile.mkstemp(
95
+ dir=os.path.dirname(dst), prefix=".auth.json.", suffix=".tmp"
96
+ )
97
+ with os.fdopen(fd, "wb") as fout:
98
+ with open(src, "rb") as fin:
99
+ shutil.copyfileobj(fin, fout)
100
+ os.chmod(tmp_path, 0o600)
101
+ os.replace(tmp_path, dst)
102
+ tmp_path = None
103
+ return True
104
+ except OSError as e:
105
+ logger.error("Atomic copy auth -> %s failed: %s", dst, e)
106
+ return False
107
+ finally:
108
+ if tmp_path is not None:
109
+ try:
110
+ os.unlink(tmp_path)
111
+ except OSError:
112
+ pass
113
+
114
+
115
+ def _promote_session_auth(grok_dir: str) -> bool:
116
+ """Treat a regular session auth.json as refreshed credentials.
117
+
118
+ The CLI refreshes tokens via temp-file + rename; the rename replaces the
119
+ session symlink with a regular file, so the new credentials (incl. the
120
+ rotated refresh token) exist only in the session while host keeps the
121
+ already-revoked old ones. Promote the session file back to host
122
+ atomically, then re-link the session to the host file. The session
123
+ file is never unlinked when the copy fails or the non-empty
124
+ validation fails; after a successful copy the re-link step does
125
+ unlink the session file and rebuild the symlink (the new credentials
126
+ already live in host at that point, so no data is lost). Creates the
127
+ host dir (0o700) when missing.
128
+ """
129
+ dest = os.path.join(grok_dir, "auth.json")
130
+ if not _session_auth_is_regular(dest):
131
+ return True
132
+ host = os.path.join(_host_grok_dir(), "auth.json")
133
+ host_dir = os.path.dirname(host)
134
+ try:
135
+ if not os.path.isdir(host_dir):
136
+ os.makedirs(host_dir, mode=0o700, exist_ok=True)
137
+ except OSError as e:
138
+ logger.error("Cannot create host grok dir %s: %s", host_dir, e)
139
+ return False
140
+ try:
141
+ if os.path.getsize(dest) <= 0:
142
+ logger.error(
143
+ "Refusing to promote empty session auth %s (host %s untouched)",
144
+ dest,
145
+ host,
146
+ )
147
+ return False
148
+ except OSError as e:
149
+ logger.error("Cannot stat session auth %s: %s", dest, e)
150
+ return False
151
+ if not _atomic_copy_auth(dest, host):
152
+ logger.error(
153
+ "Promote session auth failed; keeping %s (host %s untouched)", dest, host
154
+ )
155
+ return False
156
+ try:
157
+ host_real = os.path.realpath(host)
158
+ os.unlink(dest)
159
+ os.symlink(host_real, dest)
160
+ logger.info("Promoted session auth.json -> %s", host_real)
161
+ return True
162
+ except OSError as e:
163
+ logger.error("Re-link %s after promote failed: %s", dest, e)
164
+ return False
165
+
166
+
76
167
  def _sync_grok_auth(grok_dir: str) -> bool:
77
168
  """Make session .grok/auth.json track the host login credentials.
78
169
 
79
- Bug fixed: we used to copy auth.json only once. Token refresh updates the
80
- host ~/.grok/auth.json, while the session kept a stale/missing copy →
81
- false "Not signed in" even when host login is still valid.
82
-
83
- Strategy: symlink session auth.json → host auth.json (shared refresh).
84
- Fall back to copy2 if symlink is not possible.
170
+ CLI refresh writes HOME/.grok/auth.json via temp-file + rename; the
171
+ rename replaces a session symlink with a regular file, so refreshed
172
+ credentials (incl. the rotated refresh token) land only in the session
173
+ while host keeps the old, already-revoked ones. A regular session
174
+ auth.json is therefore always treated as newer credentials: promote it
175
+ back to host atomically, then re-link the session to the host file. On
176
+ promote failure the session file is never unlinked. Falls back to copy2
177
+ if symlink is not possible.
85
178
  Returns True if credentials are available for the child process.
86
179
  """
87
180
  auth_src = os.path.join(_host_grok_dir(), "auth.json")
88
181
  auth_dst = os.path.join(grok_dir, "auth.json")
89
182
 
183
+ # 普通文件 = CLI rename 拆掉 symlink 后留下的更新凭证,先回流 host
184
+ if _session_auth_is_regular(auth_dst):
185
+ if not _promote_session_auth(grok_dir):
186
+ # promote 失败:session 文件是唯一的新凭证,绝不 unlink
187
+ return os.path.isfile(auth_dst)
188
+
90
189
  if not os.path.isfile(auth_src):
91
190
  logger.warning("Host grok auth missing: %s", auth_src)
92
191
  return False
@@ -559,6 +658,7 @@ async def run_grok(prompt: str, user_id: str, timeout: int = None) -> tuple:
559
658
 
560
659
  t0 = time.time()
561
660
  session_dir = ensure_user_grok(user_id)
661
+ grok_dir = os.path.join(session_dir, ".grok")
562
662
 
563
663
  if not _grok_has_credentials():
564
664
  logger.warning(
@@ -727,6 +827,11 @@ async def run_grok(prompt: str, user_id: str, timeout: int = None) -> tuple:
727
827
  "这次没处理好,请稍后再试。若一直失败,请联系管理员。",
728
828
  ), []
729
829
 
830
+ finally:
831
+ # 子进程结束后立刻 harvest:CLI 可能刚用 rename 拆掉 symlink 刷新了
832
+ # 凭证,普通文件一律回流 host(成功/非零退出/超时/取消均覆盖)
833
+ _promote_session_auth(grok_dir)
834
+
730
835
 
731
836
  async def _run_grok_subcommand(subcmd_args: list, user_id: str) -> str:
732
837
  """Run a grok subcommand (e.g., 'models', 'agent') and return cleaned output.
@@ -735,6 +840,7 @@ async def _run_grok_subcommand(subcmd_args: list, user_id: str) -> str:
735
840
  Uses per-user session isolation matching run_grok.
736
841
  """
737
842
  session_dir = ensure_user_grok(user_id)
843
+ grok_dir = os.path.join(session_dir, ".grok")
738
844
  cmd = [config.grok_binary_path] + subcmd_args
739
845
  process = None
740
846
  try:
@@ -782,6 +888,9 @@ async def _run_grok_subcommand(subcmd_args: list, user_id: str) -> str:
782
888
  "这次没处理好,请稍后再试。若一直失败,请联系管理员。",
783
889
  )
784
890
 
891
+ finally:
892
+ _promote_session_auth(grok_dir)
893
+
785
894
 
786
895
  # ---------------------------------------------------------------------------
787
896
  # Slash command support
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wechatbridge-cli
3
- Version: 1.4.7
3
+ Version: 1.4.9
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
@@ -78,13 +78,13 @@ Per-user switch: `/backend agy`, `/backend grok`, or `/backend codex`. Each back
78
78
  ### Grok backend notes
79
79
 
80
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.
81
+ - Auth: the session links to the host `~/.grok/auth.json` (copied as a fallback), reusing the host `grok login`. The grok CLI rewrites `auth.json` via temp-file + rename, which replaces the session symlink with a regular file; the bridge promotes such session files back to the host (atomic copy) and re-links, so refreshed tokens never get overwritten by the revoked host copy. 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
82
 
83
83
  ### Codex backend notes
84
84
 
85
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.
86
86
  - Isolation: each WeChat user runs with `HOME` and `CODEX_HOME` pointed at their own per-user session directory (`session_dir/.codex`), so sessions, logs, and caches never cross users.
87
- - Auth: the per-user session links to the host `~/.codex/auth.json` (copied as a fallback), reusing the host `codex login`. Alternatively, set `CODEX_API_KEY` in the bridge process environment to authenticate. No key or token values are stored in this repository.
87
+ - Auth: the per-user session links to the host `~/.codex/auth.json` (copied as a fallback), reusing the host `codex login`. The codex CLI rewrites `auth.json` via temp-file + rename, which replaces the session symlink with a regular file; the bridge promotes such session files back to the host (atomic copy) and re-links, so refreshed tokens never get overwritten by the revoked host copy. Alternatively, set `CODEX_API_KEY` in the bridge process environment to authenticate. No key or token values are stored in this repository.
88
88
  - **Status:** there is currently no real Codex subscription or CLI available for live testing. The codex backend is implemented from source research, a JSONL fixture, and a fake CLI used by the test suite (which passes). Final acceptance depends on a real user running it against the actual Codex CLI.
89
89
 
90
90
  ## Prerequisites
@@ -2,6 +2,8 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  tests/test_codex.py
5
+ tests/test_codex_auth.py
6
+ tests/test_grok_auth.py
5
7
  tests/test_hardening.py
6
8
  wechatbridge/__init__.py
7
9
  wechatbridge/__main__.py