wechatbridge-cli 1.4.6__tar.gz → 1.4.8__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.6/wechatbridge_cli.egg-info → wechatbridge_cli-1.4.8}/PKG-INFO +6 -1
- {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.8}/README.md +5 -0
- {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.8}/tests/test_codex.py +28 -0
- {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.8}/tests/test_hardening.py +90 -0
- {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.8}/wechatbridge/__init__.py +1 -1
- {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.8}/wechatbridge/codex.py +59 -23
- {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.8}/wechatbridge/grok.py +182 -25
- {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.8/wechatbridge_cli.egg-info}/PKG-INFO +6 -1
- {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.8}/LICENSE +0 -0
- {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.8}/pyproject.toml +0 -0
- {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.8}/setup.cfg +0 -0
- {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.8}/wechatbridge/__main__.py +0 -0
- {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.8}/wechatbridge/agy.py +0 -0
- {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.8}/wechatbridge/config.py +0 -0
- {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.8}/wechatbridge/ilink.py +0 -0
- {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.8}/wechatbridge/main.py +0 -0
- {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.8}/wechatbridge/runner_common.py +0 -0
- {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.8}/wechatbridge/update_check.py +0 -0
- {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.8}/wechatbridge_cli.egg-info/SOURCES.txt +0 -0
- {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.8}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
- {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.8}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
- {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.8}/wechatbridge_cli.egg-info/requires.txt +0 -0
- {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.8}/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.8
|
|
4
4
|
Summary: Bridge WeChat messages to agy, Grok Build, or Codex CLIs — text/image/file/voice in, CLI replies and generated files back.
|
|
5
5
|
Author: WeChatBridge contributors
|
|
6
6
|
License: MIT
|
|
@@ -75,6 +75,11 @@ Default data paths expand from `~` (e.g. `~/.local/share/wechatbridge/<instance>
|
|
|
75
75
|
|
|
76
76
|
Per-user switch: `/backend agy`, `/backend grok`, or `/backend codex`. Each backend keeps its own model / effort / mode memory and persona file layout. Global default is `WECHATBRIDGE_BACKEND`.
|
|
77
77
|
|
|
78
|
+
### Grok backend notes
|
|
79
|
+
|
|
80
|
+
- Isolation: each WeChat user runs with `HOME` pointed at their own session directory. Conversation state stays there; login is machine-wide.
|
|
81
|
+
- Auth: the session links to the host `~/.grok/auth.json` (copied as a fallback), reusing the host `grok login`. Alternatively, set `XAI_API_KEY` in the bridge process environment (the grok child is given that key after env sanitizing). A grok-remote TUI session being signed in is not the same as the host CLI login. No key or token values are stored in this repository.
|
|
82
|
+
|
|
78
83
|
### Codex backend notes
|
|
79
84
|
|
|
80
85
|
- Runs `codex exec --json` for each single turn; conversation continuation uses `codex exec resume <thread_id> <prompt>` with the thread id persisted per user.
|
|
@@ -44,6 +44,11 @@ Default data paths expand from `~` (e.g. `~/.local/share/wechatbridge/<instance>
|
|
|
44
44
|
|
|
45
45
|
Per-user switch: `/backend agy`, `/backend grok`, or `/backend codex`. Each backend keeps its own model / effort / mode memory and persona file layout. Global default is `WECHATBRIDGE_BACKEND`.
|
|
46
46
|
|
|
47
|
+
### Grok backend notes
|
|
48
|
+
|
|
49
|
+
- Isolation: each WeChat user runs with `HOME` pointed at their own session directory. Conversation state stays there; login is machine-wide.
|
|
50
|
+
- Auth: the session links to the host `~/.grok/auth.json` (copied as a fallback), reusing the host `grok login`. Alternatively, set `XAI_API_KEY` in the bridge process environment (the grok child is given that key after env sanitizing). A grok-remote TUI session being signed in is not the same as the host CLI login. No key or token values are stored in this repository.
|
|
51
|
+
|
|
47
52
|
### Codex backend notes
|
|
48
53
|
|
|
49
54
|
- Runs `codex exec --json` for each single turn; conversation continuation uses `codex exec resume <thread_id> <prompt>` with the thread id persisted per user.
|
|
@@ -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")
|
|
@@ -649,6 +649,96 @@ class TestGrokRelativePathArtifacts(unittest.TestCase):
|
|
|
649
649
|
self.assertEqual(path, os.path.abspath(abs_file))
|
|
650
650
|
self.assertTrue(os.path.isabs(path))
|
|
651
651
|
|
|
652
|
+
def test_search_replace_path_key(self):
|
|
653
|
+
from wechatbridge.grok import _extract_grok_artifacts
|
|
654
|
+
import urllib.parse
|
|
655
|
+
|
|
656
|
+
with tempfile.TemporaryDirectory() as session_dir:
|
|
657
|
+
abs_file = os.path.join(session_dir, "report.docx")
|
|
658
|
+
with open(abs_file, "w", encoding="utf-8") as f:
|
|
659
|
+
f.write("doc")
|
|
660
|
+
session_id = "sess-sr-1"
|
|
661
|
+
hist_dir = os.path.join(
|
|
662
|
+
session_dir,
|
|
663
|
+
".grok",
|
|
664
|
+
"sessions",
|
|
665
|
+
urllib.parse.quote(session_dir, safe=""),
|
|
666
|
+
session_id,
|
|
667
|
+
)
|
|
668
|
+
os.makedirs(hist_dir)
|
|
669
|
+
line = {
|
|
670
|
+
"type": "assistant",
|
|
671
|
+
"tool_calls": [
|
|
672
|
+
{
|
|
673
|
+
"name": "search_replace",
|
|
674
|
+
"arguments": {"path": "report.docx"},
|
|
675
|
+
}
|
|
676
|
+
],
|
|
677
|
+
}
|
|
678
|
+
with open(os.path.join(hist_dir, "chat_history.jsonl"), "w", encoding="utf-8") as f:
|
|
679
|
+
f.write(json.dumps(line) + "\n")
|
|
680
|
+
arts = _extract_grok_artifacts(session_dir, session_id, since=0.0)
|
|
681
|
+
self.assertEqual(arts, [("report.docx", os.path.abspath(abs_file))])
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
class TestGrokSessionScanArtifacts(unittest.TestCase):
|
|
685
|
+
def test_scan_picks_new_pdf_skips_bundled(self):
|
|
686
|
+
from wechatbridge.grok import _scan_grok_session_artifacts, _merge_grok_artifacts
|
|
687
|
+
|
|
688
|
+
with tempfile.TemporaryDirectory() as session_dir:
|
|
689
|
+
pdf = os.path.join(session_dir, "out.pdf")
|
|
690
|
+
with open(pdf, "wb") as f:
|
|
691
|
+
f.write(b"%PDF-1.4")
|
|
692
|
+
bundled_dir = os.path.join(session_dir, ".grok", "bundled", "skills", "pdf")
|
|
693
|
+
os.makedirs(bundled_dir)
|
|
694
|
+
bundled = os.path.join(bundled_dir, "form.pdf")
|
|
695
|
+
with open(bundled, "wb") as f:
|
|
696
|
+
f.write(b"%PDF-1.4")
|
|
697
|
+
old = os.path.join(session_dir, "old.docx")
|
|
698
|
+
with open(old, "wb") as f:
|
|
699
|
+
f.write(b"PK")
|
|
700
|
+
os.utime(old, (1_000_000, 1_000_000))
|
|
701
|
+
arts = _scan_grok_session_artifacts(session_dir, since=time.time())
|
|
702
|
+
paths = {p for _, p in arts}
|
|
703
|
+
self.assertIn(os.path.realpath(pdf), paths)
|
|
704
|
+
self.assertNotIn(os.path.realpath(bundled), paths)
|
|
705
|
+
self.assertNotIn(os.path.realpath(old), paths)
|
|
706
|
+
|
|
707
|
+
def test_merge_dedupes_by_realpath(self):
|
|
708
|
+
from wechatbridge.grok import _merge_grok_artifacts
|
|
709
|
+
|
|
710
|
+
with tempfile.TemporaryDirectory() as td:
|
|
711
|
+
p = os.path.join(td, "a.pdf")
|
|
712
|
+
with open(p, "wb") as f:
|
|
713
|
+
f.write(b"x")
|
|
714
|
+
merged = _merge_grok_artifacts(
|
|
715
|
+
[("a.pdf", p)],
|
|
716
|
+
[("a.pdf", os.path.realpath(p))],
|
|
717
|
+
)
|
|
718
|
+
self.assertEqual(merged, [("a.pdf", p)])
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
class TestGrokAuthPassthrough(unittest.TestCase):
|
|
722
|
+
def test_apply_reinjects_xai_key(self):
|
|
723
|
+
from wechatbridge.grok import _apply_grok_runtime_env, _grok_has_credentials
|
|
724
|
+
from wechatbridge.runner_common import sanitize_env
|
|
725
|
+
|
|
726
|
+
with tempfile.TemporaryDirectory() as td:
|
|
727
|
+
with mock.patch.dict(os.environ, {"XAI_API_KEY": "secret-xai"}, clear=False):
|
|
728
|
+
env = sanitize_env(td)
|
|
729
|
+
self.assertNotIn("XAI_API_KEY", env)
|
|
730
|
+
env = _apply_grok_runtime_env(env)
|
|
731
|
+
self.assertEqual(env["XAI_API_KEY"], "secret-xai")
|
|
732
|
+
self.assertTrue(_grok_has_credentials())
|
|
733
|
+
|
|
734
|
+
def test_has_credentials_false_without_auth_or_key(self):
|
|
735
|
+
from wechatbridge.grok import _grok_has_credentials
|
|
736
|
+
|
|
737
|
+
with mock.patch("wechatbridge.grok._host_grok_dir", return_value="/no/such/grok-home"):
|
|
738
|
+
with mock.patch.dict(os.environ, {"XAI_API_KEY": ""}, clear=False):
|
|
739
|
+
os.environ.pop("XAI_API_KEY", None)
|
|
740
|
+
self.assertFalse(_grok_has_credentials())
|
|
741
|
+
|
|
652
742
|
|
|
653
743
|
class TestILinkDeliveryAccepted(unittest.TestCase):
|
|
654
744
|
def test_predicate(self):
|
|
@@ -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.8"
|
|
@@ -633,26 +633,52 @@ def _extract_codex_error_messages(stdout_text: str) -> list:
|
|
|
633
633
|
|
|
634
634
|
|
|
635
635
|
def _collect_fallback_artifacts(session_dir: str, snapshot_before: dict, t0: float) -> list:
|
|
636
|
-
"""Scan session_dir for
|
|
636
|
+
"""Scan session_dir for files created or overwritten during this turn.
|
|
637
637
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
638
|
+
Codex often emits a ``file_change`` for a helper script and then writes
|
|
639
|
+
the real pdf/docx via the shell. Always merge this list with structured
|
|
640
|
+
artifacts (dedupe by realpath). Scans only session_dir (never add_dirs).
|
|
641
|
+
Excludes internal metadata dirs. Each candidate is verified under session_dir.
|
|
641
642
|
"""
|
|
642
643
|
_internal_names = {".codex_thread_id"}
|
|
643
644
|
fallback = []
|
|
644
645
|
fallback_seen = set()
|
|
645
646
|
for fp, fmtime in _snapshot_regular_files([session_dir]).items():
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
if
|
|
647
|
+
prev = snapshot_before.get(fp)
|
|
648
|
+
if prev is None:
|
|
649
|
+
if fmtime < t0:
|
|
649
650
|
continue
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
651
|
+
elif fmtime <= prev:
|
|
652
|
+
continue
|
|
653
|
+
name = os.path.basename(fp)
|
|
654
|
+
if name in _internal_names or name.startswith(".initialized."):
|
|
655
|
+
continue
|
|
656
|
+
key = (name, fp)
|
|
657
|
+
if key not in fallback_seen:
|
|
658
|
+
fallback_seen.add(key)
|
|
659
|
+
fallback.append(key)
|
|
654
660
|
return fallback
|
|
655
661
|
|
|
662
|
+
|
|
663
|
+
def _merge_codex_artifacts(*groups) -> list:
|
|
664
|
+
"""Dedupe (name, path) tuples by realpath, preserve first-seen order."""
|
|
665
|
+
merged = []
|
|
666
|
+
seen = set()
|
|
667
|
+
for group in groups:
|
|
668
|
+
for item in group or []:
|
|
669
|
+
if not isinstance(item, (tuple, list)) or len(item) != 2:
|
|
670
|
+
continue
|
|
671
|
+
name, path = item
|
|
672
|
+
try:
|
|
673
|
+
key = os.path.realpath(path)
|
|
674
|
+
except OSError:
|
|
675
|
+
key = path
|
|
676
|
+
if key in seen:
|
|
677
|
+
continue
|
|
678
|
+
seen.add(key)
|
|
679
|
+
merged.append((name, path))
|
|
680
|
+
return merged
|
|
681
|
+
|
|
656
682
|
async def run_codex(prompt: str, user_id: str, timeout: int = None) -> tuple:
|
|
657
683
|
"""Execute codex CLI for a given user message.
|
|
658
684
|
|
|
@@ -831,12 +857,18 @@ async def run_codex(prompt: str, user_id: str, timeout: int = None) -> tuple:
|
|
|
831
857
|
mark_initialized(session_dir, backend="codex")
|
|
832
858
|
if r_thread_id:
|
|
833
859
|
_write_codex_thread_id(session_dir, r_thread_id)
|
|
834
|
-
# Fallback:
|
|
835
|
-
if not r_failed
|
|
836
|
-
|
|
837
|
-
if
|
|
838
|
-
|
|
839
|
-
|
|
860
|
+
# Fallback: 重试成功也扫本轮新建/覆盖的文件,与 file_change 合并
|
|
861
|
+
if not r_failed:
|
|
862
|
+
extra = _collect_fallback_artifacts(session_dir, snapshot_before, t0)
|
|
863
|
+
if extra:
|
|
864
|
+
before = len(r_artifacts)
|
|
865
|
+
r_artifacts = _merge_codex_artifacts(r_artifacts, extra)
|
|
866
|
+
added = len(r_artifacts) - before
|
|
867
|
+
if added:
|
|
868
|
+
logger.info(
|
|
869
|
+
"Fallback scan collected %d additional files on retry",
|
|
870
|
+
added,
|
|
871
|
+
)
|
|
840
872
|
elapsed = time.time() - t0
|
|
841
873
|
logger.info(
|
|
842
874
|
"codex retry done: user=%s elapsed=%.1fs artifacts=%d output=%d chars failed=%s",
|
|
@@ -850,12 +882,16 @@ async def run_codex(prompt: str, user_id: str, timeout: int = None) -> tuple:
|
|
|
850
882
|
if parsed_thread_id:
|
|
851
883
|
_write_codex_thread_id(session_dir, parsed_thread_id)
|
|
852
884
|
|
|
853
|
-
# Fallback:
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
885
|
+
# Fallback: 成功就扫本轮新建/覆盖的文件,与 file_change 合并去重。
|
|
886
|
+
# 不能再要求 artifacts 为空——先写出脚本再终端生成 pdf 时会漏文档。
|
|
887
|
+
if not failed:
|
|
888
|
+
extra = _collect_fallback_artifacts(session_dir, snapshot_before, t0)
|
|
889
|
+
if extra:
|
|
890
|
+
before = len(artifacts)
|
|
891
|
+
artifacts = _merge_codex_artifacts(artifacts, extra)
|
|
892
|
+
added = len(artifacts) - before
|
|
893
|
+
if added:
|
|
894
|
+
logger.info("Fallback scan collected %d additional files", added)
|
|
859
895
|
|
|
860
896
|
elapsed = time.time() - t0
|
|
861
897
|
logger.info(
|
|
@@ -19,6 +19,7 @@ from .runner_common import (
|
|
|
19
19
|
clean_output, load_prefs, save_prefs, is_dangerous, parse_model_effort,
|
|
20
20
|
sanitize_env, terminate_process, update_active_prefs,
|
|
21
21
|
format_error, format_cli_error, is_bridge_formatted_reply, EMPTY_REPLY, validate_add_dir,
|
|
22
|
+
path_is_under,
|
|
22
23
|
)
|
|
23
24
|
|
|
24
25
|
logger = logging.getLogger("grok_runner")
|
|
@@ -26,6 +27,37 @@ logger = logging.getLogger("grok_runner")
|
|
|
26
27
|
# execve 单参数上限(Linux MAX_ARG_STRLEN = 128KB),留安全余量
|
|
27
28
|
_MAX_ARG_BYTES = 120 * 1024
|
|
28
29
|
|
|
30
|
+
# grok CLI 写文件工具名(新旧混用)。只用这些调用的 path 抽产物会漏掉
|
|
31
|
+
# 经 run_terminal_command 生成的 pdf/docx,所以后面还有目录扫描兜底。
|
|
32
|
+
_GROK_WRITE_TOOLS = frozenset({
|
|
33
|
+
"write",
|
|
34
|
+
"edit",
|
|
35
|
+
"str_replace",
|
|
36
|
+
"search_replace",
|
|
37
|
+
"Write",
|
|
38
|
+
"Edit",
|
|
39
|
+
"StrReplace",
|
|
40
|
+
"SearchReplace",
|
|
41
|
+
})
|
|
42
|
+
_GROK_PATH_KEYS = ("file_path", "path", "target_file")
|
|
43
|
+
_GROK_PASSTHROUGH_ENV = ("XAI_API_KEY",)
|
|
44
|
+
_GROK_SKIP_DIR_NAMES = frozenset({
|
|
45
|
+
".grok",
|
|
46
|
+
".gemini",
|
|
47
|
+
".codex",
|
|
48
|
+
".git",
|
|
49
|
+
"__pycache__",
|
|
50
|
+
"node_modules",
|
|
51
|
+
"venv",
|
|
52
|
+
".venv",
|
|
53
|
+
"cache",
|
|
54
|
+
".cache",
|
|
55
|
+
})
|
|
56
|
+
_GROK_SKIP_FILE_NAMES = frozenset({
|
|
57
|
+
"prefs.json",
|
|
58
|
+
"grok_persona.txt",
|
|
59
|
+
})
|
|
60
|
+
|
|
29
61
|
|
|
30
62
|
# ---------------------------------------------------------------------------
|
|
31
63
|
# Per-user .grok directory setup
|
|
@@ -108,6 +140,28 @@ def ensure_user_grok(user_id: str) -> str:
|
|
|
108
140
|
return session_dir
|
|
109
141
|
|
|
110
142
|
|
|
143
|
+
def _grok_has_credentials() -> bool:
|
|
144
|
+
"""True if the grok child can authenticate: host auth.json or XAI_API_KEY."""
|
|
145
|
+
if os.path.isfile(os.path.join(_host_grok_dir(), "auth.json")):
|
|
146
|
+
return True
|
|
147
|
+
key = os.environ.get("XAI_API_KEY")
|
|
148
|
+
return bool(key and str(key).strip())
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _apply_grok_runtime_env(env: dict) -> dict:
|
|
152
|
+
"""Re-inject grok auth env after sanitize_env (which strips *API_KEY).
|
|
153
|
+
|
|
154
|
+
Host `grok login` uses ~/.grok/auth.json (symlinked into the session).
|
|
155
|
+
Headless alternative is XAI_API_KEY on the bridge process; without this
|
|
156
|
+
passthrough the child always sees "Not signed in".
|
|
157
|
+
"""
|
|
158
|
+
for key in _GROK_PASSTHROUGH_ENV:
|
|
159
|
+
val = os.environ.get(key)
|
|
160
|
+
if val:
|
|
161
|
+
env[key] = val
|
|
162
|
+
return env
|
|
163
|
+
|
|
164
|
+
|
|
111
165
|
# ---------------------------------------------------------------------------
|
|
112
166
|
# Persona persistence (via --rules injection)
|
|
113
167
|
# ---------------------------------------------------------------------------
|
|
@@ -280,11 +334,43 @@ def _has_grok_session(session_dir: str) -> bool:
|
|
|
280
334
|
# Artifact extraction from chat_history.jsonl
|
|
281
335
|
# ---------------------------------------------------------------------------
|
|
282
336
|
|
|
337
|
+
def _grok_tool_path(args) -> str:
|
|
338
|
+
"""Path from a grok write/edit tool argument object."""
|
|
339
|
+
if not isinstance(args, dict):
|
|
340
|
+
return ""
|
|
341
|
+
for key in _GROK_PATH_KEYS:
|
|
342
|
+
val = args.get(key)
|
|
343
|
+
if isinstance(val, str) and val.strip():
|
|
344
|
+
return val.strip()
|
|
345
|
+
return ""
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _resolve_grok_artifact_path(fp: str, session_dir: str, since: float, seen: set, artifacts: list) -> None:
|
|
349
|
+
"""Resolve one candidate path and append if it is a new file from this turn."""
|
|
350
|
+
if not fp:
|
|
351
|
+
return
|
|
352
|
+
if not os.path.isabs(fp):
|
|
353
|
+
fp = os.path.join(session_dir, fp)
|
|
354
|
+
try:
|
|
355
|
+
fp = os.path.abspath(fp)
|
|
356
|
+
except (OSError, ValueError):
|
|
357
|
+
return
|
|
358
|
+
try:
|
|
359
|
+
if since and os.path.getmtime(fp) < since - 2.0:
|
|
360
|
+
return
|
|
361
|
+
except OSError:
|
|
362
|
+
return
|
|
363
|
+
key = (os.path.basename(fp), fp)
|
|
364
|
+
if key not in seen:
|
|
365
|
+
seen.add(key)
|
|
366
|
+
artifacts.append(key)
|
|
367
|
+
|
|
368
|
+
|
|
283
369
|
def _extract_grok_artifacts(session_dir: str, session_id: str, since: float = 0.0) -> list:
|
|
284
370
|
"""Extract (name, abs_path) tuples from grok session chat_history.jsonl.
|
|
285
371
|
|
|
286
372
|
grok stores sessions under $HOME/.grok/sessions/<url-encoded-cwd>/<session-id>/.
|
|
287
|
-
The chat_history.jsonl contains structured tool_calls with
|
|
373
|
+
The chat_history.jsonl contains structured tool_calls with path arguments
|
|
288
374
|
from write/edit operations.
|
|
289
375
|
|
|
290
376
|
``since``: 只收录 mtime >= since 的文件——chat_history.jsonl 跨轮累积,
|
|
@@ -315,6 +401,8 @@ def _extract_grok_artifacts(session_dir: str, session_id: str, since: float = 0.
|
|
|
315
401
|
continue
|
|
316
402
|
if d.get("type") == "assistant" and d.get("tool_calls"):
|
|
317
403
|
for tc in d.get("tool_calls", []):
|
|
404
|
+
if not isinstance(tc, dict):
|
|
405
|
+
continue
|
|
318
406
|
name = tc.get("name", "")
|
|
319
407
|
args = tc.get("arguments", "")
|
|
320
408
|
if isinstance(args, str):
|
|
@@ -322,28 +410,11 @@ def _extract_grok_artifacts(session_dir: str, session_id: str, since: float = 0.
|
|
|
322
410
|
args = json.loads(args)
|
|
323
411
|
except json.JSONDecodeError:
|
|
324
412
|
continue
|
|
325
|
-
if name in
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
if not os.path.isabs(fp):
|
|
331
|
-
fp = os.path.join(session_dir, fp)
|
|
332
|
-
try:
|
|
333
|
-
fp = os.path.abspath(fp)
|
|
334
|
-
except (OSError, ValueError):
|
|
335
|
-
continue
|
|
336
|
-
# 只收录本轮运行期间新写/修改的文件
|
|
337
|
-
try:
|
|
338
|
-
if since and os.path.getmtime(fp) < since - 2.0:
|
|
339
|
-
continue
|
|
340
|
-
except OSError:
|
|
341
|
-
continue # 文件已不存在,无需回传
|
|
342
|
-
art_name = os.path.basename(fp)
|
|
343
|
-
key = (art_name, fp)
|
|
344
|
-
if key not in seen:
|
|
345
|
-
seen.add(key)
|
|
346
|
-
artifacts.append(key)
|
|
413
|
+
if name not in _GROK_WRITE_TOOLS:
|
|
414
|
+
continue
|
|
415
|
+
_resolve_grok_artifact_path(
|
|
416
|
+
_grok_tool_path(args), session_dir, since, seen, artifacts
|
|
417
|
+
)
|
|
347
418
|
except OSError as e:
|
|
348
419
|
logger.warning("Failed to read chat_history.jsonl: %s", e)
|
|
349
420
|
|
|
@@ -352,6 +423,78 @@ def _extract_grok_artifacts(session_dir: str, session_id: str, since: float = 0.
|
|
|
352
423
|
return artifacts
|
|
353
424
|
|
|
354
425
|
|
|
426
|
+
def _scan_grok_session_artifacts(session_dir: str, since: float = 0.0) -> list:
|
|
427
|
+
"""Collect regular files written under session_dir during this turn.
|
|
428
|
+
|
|
429
|
+
grok often creates pdf/docx via run_terminal_command, which never appears
|
|
430
|
+
in write/edit tool_calls. Skip .grok / .gemini / .codex and other internal
|
|
431
|
+
trees so bundled skill PDFs are not sent back.
|
|
432
|
+
|
|
433
|
+
Bounded like the codex fallback: empty result if the walk exceeds
|
|
434
|
+
200 files, 50 directories, or 2 seconds.
|
|
435
|
+
"""
|
|
436
|
+
if not since or not session_dir or not os.path.isdir(session_dir):
|
|
437
|
+
return []
|
|
438
|
+
|
|
439
|
+
artifacts = []
|
|
440
|
+
seen = set()
|
|
441
|
+
cutoff = since - 2.0
|
|
442
|
+
t_start = time.monotonic()
|
|
443
|
+
file_count = 0
|
|
444
|
+
dir_count = 0
|
|
445
|
+
max_files = 200
|
|
446
|
+
max_dirs = 50
|
|
447
|
+
max_scan_time = 2.0
|
|
448
|
+
|
|
449
|
+
for dirpath, dirnames, filenames in os.walk(session_dir, followlinks=False):
|
|
450
|
+
dir_count += 1
|
|
451
|
+
if dir_count > max_dirs or time.monotonic() - t_start > max_scan_time:
|
|
452
|
+
return []
|
|
453
|
+
dirnames[:] = [d for d in dirnames if d not in _GROK_SKIP_DIR_NAMES]
|
|
454
|
+
for fn in filenames:
|
|
455
|
+
file_count += 1
|
|
456
|
+
if file_count > max_files:
|
|
457
|
+
return []
|
|
458
|
+
if fn in _GROK_SKIP_FILE_NAMES or fn.startswith(".initialized."):
|
|
459
|
+
continue
|
|
460
|
+
fp = os.path.join(dirpath, fn)
|
|
461
|
+
try:
|
|
462
|
+
if os.path.islink(fp) or not os.path.isfile(fp):
|
|
463
|
+
continue
|
|
464
|
+
if os.path.getmtime(fp) < cutoff:
|
|
465
|
+
continue
|
|
466
|
+
if not path_is_under(fp, session_dir):
|
|
467
|
+
continue
|
|
468
|
+
real = os.path.realpath(fp)
|
|
469
|
+
except OSError:
|
|
470
|
+
continue
|
|
471
|
+
key = (os.path.basename(real), real)
|
|
472
|
+
if key not in seen:
|
|
473
|
+
seen.add(key)
|
|
474
|
+
artifacts.append(key)
|
|
475
|
+
return artifacts
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _merge_grok_artifacts(*groups) -> list:
|
|
479
|
+
"""Dedupe (name, path) tuples by realpath, preserve first-seen order."""
|
|
480
|
+
merged = []
|
|
481
|
+
seen = set()
|
|
482
|
+
for group in groups:
|
|
483
|
+
for item in group or []:
|
|
484
|
+
if not isinstance(item, (tuple, list)) or len(item) != 2:
|
|
485
|
+
continue
|
|
486
|
+
name, path = item
|
|
487
|
+
try:
|
|
488
|
+
key = os.path.realpath(path)
|
|
489
|
+
except OSError:
|
|
490
|
+
key = path
|
|
491
|
+
if key in seen:
|
|
492
|
+
continue
|
|
493
|
+
seen.add(key)
|
|
494
|
+
merged.append((name, path))
|
|
495
|
+
return merged
|
|
496
|
+
|
|
497
|
+
|
|
355
498
|
def _parse_grok_output(stdout_text: str, session_dir: str, since: float = 0.0) -> tuple:
|
|
356
499
|
"""Parse grok JSON output into (display_text, artifacts).
|
|
357
500
|
|
|
@@ -378,6 +521,10 @@ def _parse_grok_output(stdout_text: str, session_dir: str, since: float = 0.0) -
|
|
|
378
521
|
artifacts = []
|
|
379
522
|
if session_id:
|
|
380
523
|
artifacts = _extract_grok_artifacts(session_dir, session_id, since=since)
|
|
524
|
+
# Merge directory scan so pdf/docx created via shell still go back.
|
|
525
|
+
artifacts = _merge_grok_artifacts(
|
|
526
|
+
artifacts, _scan_grok_session_artifacts(session_dir, since)
|
|
527
|
+
)
|
|
381
528
|
|
|
382
529
|
# Strip file:/// links from display (in case grok emits them)
|
|
383
530
|
display = re.sub(
|
|
@@ -413,6 +560,16 @@ async def run_grok(prompt: str, user_id: str, timeout: int = None) -> tuple:
|
|
|
413
560
|
t0 = time.time()
|
|
414
561
|
session_dir = ensure_user_grok(user_id)
|
|
415
562
|
|
|
563
|
+
if not _grok_has_credentials():
|
|
564
|
+
logger.warning(
|
|
565
|
+
"grok credentials missing: no host auth.json and no XAI_API_KEY"
|
|
566
|
+
)
|
|
567
|
+
return format_cli_error(
|
|
568
|
+
"Not signed in. To authenticate without a browser, run:\n"
|
|
569
|
+
" grok login --device-code",
|
|
570
|
+
backend="grok",
|
|
571
|
+
), []
|
|
572
|
+
|
|
416
573
|
# Audit logging
|
|
417
574
|
logger.info("[AUDIT] user=%s prompt=%.200s", user_id, prompt)
|
|
418
575
|
if is_dangerous(prompt):
|
|
@@ -441,7 +598,7 @@ async def run_grok(prompt: str, user_id: str, timeout: int = None) -> tuple:
|
|
|
441
598
|
|
|
442
599
|
process = None
|
|
443
600
|
try:
|
|
444
|
-
env = sanitize_env(session_dir)
|
|
601
|
+
env = _apply_grok_runtime_env(sanitize_env(session_dir))
|
|
445
602
|
env["PAGER"] = "cat"
|
|
446
603
|
env["CI"] = "true"
|
|
447
604
|
env["NONINTERACTIVE"] = "1"
|
|
@@ -581,7 +738,7 @@ async def _run_grok_subcommand(subcmd_args: list, user_id: str) -> str:
|
|
|
581
738
|
cmd = [config.grok_binary_path] + subcmd_args
|
|
582
739
|
process = None
|
|
583
740
|
try:
|
|
584
|
-
env = sanitize_env(session_dir)
|
|
741
|
+
env = _apply_grok_runtime_env(sanitize_env(session_dir))
|
|
585
742
|
process = await asyncio.create_subprocess_exec(
|
|
586
743
|
*cmd,
|
|
587
744
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wechatbridge-cli
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.8
|
|
4
4
|
Summary: Bridge WeChat messages to agy, Grok Build, or Codex CLIs — text/image/file/voice in, CLI replies and generated files back.
|
|
5
5
|
Author: WeChatBridge contributors
|
|
6
6
|
License: MIT
|
|
@@ -75,6 +75,11 @@ Default data paths expand from `~` (e.g. `~/.local/share/wechatbridge/<instance>
|
|
|
75
75
|
|
|
76
76
|
Per-user switch: `/backend agy`, `/backend grok`, or `/backend codex`. Each backend keeps its own model / effort / mode memory and persona file layout. Global default is `WECHATBRIDGE_BACKEND`.
|
|
77
77
|
|
|
78
|
+
### Grok backend notes
|
|
79
|
+
|
|
80
|
+
- Isolation: each WeChat user runs with `HOME` pointed at their own session directory. Conversation state stays there; login is machine-wide.
|
|
81
|
+
- Auth: the session links to the host `~/.grok/auth.json` (copied as a fallback), reusing the host `grok login`. Alternatively, set `XAI_API_KEY` in the bridge process environment (the grok child is given that key after env sanitizing). A grok-remote TUI session being signed in is not the same as the host CLI login. No key or token values are stored in this repository.
|
|
82
|
+
|
|
78
83
|
### Codex backend notes
|
|
79
84
|
|
|
80
85
|
- Runs `codex exec --json` for each single turn; conversation continuation uses `codex exec resume <thread_id> <prompt>` with the thread id persisted per user.
|
|
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.6 → wechatbridge_cli-1.4.8}/wechatbridge_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.8}/wechatbridge_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|