wechatbridge-cli 1.4.8__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.8/wechatbridge_cli.egg-info → wechatbridge_cli-1.4.9}/PKG-INFO +3 -3
  2. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/README.md +2 -2
  3. wechatbridge_cli-1.4.9/tests/test_codex_auth.py +266 -0
  4. wechatbridge_cli-1.4.9/tests/test_grok_auth.py +238 -0
  5. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge/__init__.py +1 -1
  6. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge/codex.py +115 -2
  7. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge/grok.py +115 -6
  8. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9/wechatbridge_cli.egg-info}/PKG-INFO +3 -3
  9. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge_cli.egg-info/SOURCES.txt +2 -0
  10. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/LICENSE +0 -0
  11. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/pyproject.toml +0 -0
  12. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/setup.cfg +0 -0
  13. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/tests/test_codex.py +0 -0
  14. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/tests/test_hardening.py +0 -0
  15. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge/__main__.py +0 -0
  16. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge/agy.py +0 -0
  17. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge/config.py +0 -0
  18. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge/ilink.py +0 -0
  19. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge/main.py +0 -0
  20. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge/runner_common.py +0 -0
  21. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge/update_check.py +0 -0
  22. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
  23. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
  24. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge_cli.egg-info/requires.txt +0 -0
  25. {wechatbridge_cli-1.4.8 → 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.8
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
@@ -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.8"
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
@@ -706,6 +809,7 @@ async def run_codex(prompt: str, user_id: str, timeout: int = None) -> tuple:
706
809
 
707
810
  t0 = time.time()
708
811
  session_dir = ensure_user_codex(user_id)
812
+ codex_dir = os.path.join(session_dir, ".codex")
709
813
 
710
814
  # Audit logging
711
815
  logger.info("[AUDIT] user=%s prompt=%.200s", user_id, prompt)
@@ -918,6 +1022,11 @@ async def run_codex(prompt: str, user_id: str, timeout: int = None) -> tuple:
918
1022
  "这次没处理好,请稍后再试。若一直失败,请联系管理员。",
919
1023
  ), []
920
1024
 
1025
+ finally:
1026
+ # 子进程结束后立刻 harvest:CLI 可能刚用 rename 拆掉 symlink 刷新了
1027
+ # 凭证,普通文件一律回流 host(成功/非零退出/超时/取消均覆盖)
1028
+ _promote_session_auth(codex_dir)
1029
+
921
1030
 
922
1031
  # ---------------------------------------------------------------------------
923
1032
  # Slash command support
@@ -931,6 +1040,7 @@ async def _run_codex_subcommand(subcmd_args: list, user_id: str) -> str:
931
1040
  ``sanitize_env``.
932
1041
  """
933
1042
  session_dir = ensure_user_codex(user_id)
1043
+ codex_dir = os.path.join(session_dir, ".codex")
934
1044
  cmd = [config.codex_binary_path] + list(subcmd_args)
935
1045
  process = None
936
1046
  try:
@@ -986,6 +1096,9 @@ async def _run_codex_subcommand(subcmd_args: list, user_id: str) -> str:
986
1096
  "这次没处理好,请稍后再试。若一直失败,请联系管理员。",
987
1097
  )
988
1098
 
1099
+ finally:
1100
+ _promote_session_auth(codex_dir)
1101
+
989
1102
 
990
1103
  def _parse_codex_models(output: str) -> list[str]:
991
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.8
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