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.
- {wechatbridge_cli-1.4.8/wechatbridge_cli.egg-info → wechatbridge_cli-1.4.9}/PKG-INFO +3 -3
- {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/README.md +2 -2
- wechatbridge_cli-1.4.9/tests/test_codex_auth.py +266 -0
- wechatbridge_cli-1.4.9/tests/test_grok_auth.py +238 -0
- {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge/__init__.py +1 -1
- {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge/codex.py +115 -2
- {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge/grok.py +115 -6
- {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9/wechatbridge_cli.egg-info}/PKG-INFO +3 -3
- {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge_cli.egg-info/SOURCES.txt +2 -0
- {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/LICENSE +0 -0
- {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/pyproject.toml +0 -0
- {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/setup.cfg +0 -0
- {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/tests/test_codex.py +0 -0
- {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/tests/test_hardening.py +0 -0
- {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge/__main__.py +0 -0
- {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge/agy.py +0 -0
- {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge/config.py +0 -0
- {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge/ilink.py +0 -0
- {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge/main.py +0 -0
- {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge/runner_common.py +0 -0
- {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge/update_check.py +0 -0
- {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
- {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
- {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge_cli.egg-info/requires.txt +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{wechatbridge_cli-1.4.8 → wechatbridge_cli-1.4.9}/wechatbridge_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|