wechatbridge-cli 1.4.6__tar.gz → 1.4.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {wechatbridge_cli-1.4.6/wechatbridge_cli.egg-info → wechatbridge_cli-1.4.7}/PKG-INFO +6 -1
  2. {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.7}/README.md +5 -0
  3. {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.7}/tests/test_hardening.py +90 -0
  4. {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.7}/wechatbridge/__init__.py +1 -1
  5. {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.7}/wechatbridge/grok.py +182 -25
  6. {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.7/wechatbridge_cli.egg-info}/PKG-INFO +6 -1
  7. {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.7}/LICENSE +0 -0
  8. {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.7}/pyproject.toml +0 -0
  9. {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.7}/setup.cfg +0 -0
  10. {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.7}/tests/test_codex.py +0 -0
  11. {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.7}/wechatbridge/__main__.py +0 -0
  12. {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.7}/wechatbridge/agy.py +0 -0
  13. {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.7}/wechatbridge/codex.py +0 -0
  14. {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.7}/wechatbridge/config.py +0 -0
  15. {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.7}/wechatbridge/ilink.py +0 -0
  16. {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.7}/wechatbridge/main.py +0 -0
  17. {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.7}/wechatbridge/runner_common.py +0 -0
  18. {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.7}/wechatbridge/update_check.py +0 -0
  19. {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.7}/wechatbridge_cli.egg-info/SOURCES.txt +0 -0
  20. {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.7}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
  21. {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.7}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
  22. {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.7}/wechatbridge_cli.egg-info/requires.txt +0 -0
  23. {wechatbridge_cli-1.4.6 → wechatbridge_cli-1.4.7}/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.6
3
+ Version: 1.4.7
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.
@@ -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.6"
2
+ __version__ = "1.4.7"
@@ -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 file_path arguments
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 ("write", "edit", "str_replace") and isinstance(args, dict):
326
- fp = args.get("file_path", "")
327
- if not fp:
328
- continue
329
- # Relative paths resolve against session_dir (cwd)
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.6
3
+ Version: 1.4.7
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.