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