wechatbridge-cli 1.4.8__tar.gz → 1.5.0__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 (27) hide show
  1. {wechatbridge_cli-1.4.8/wechatbridge_cli.egg-info → wechatbridge_cli-1.5.0}/PKG-INFO +31 -15
  2. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.5.0}/README.md +30 -14
  3. wechatbridge_cli-1.5.0/tests/test_codex_auth.py +266 -0
  4. wechatbridge_cli-1.5.0/tests/test_dsh.py +657 -0
  5. wechatbridge_cli-1.5.0/tests/test_grok_auth.py +238 -0
  6. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.5.0}/tests/test_hardening.py +177 -0
  7. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.5.0}/wechatbridge/__init__.py +1 -1
  8. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.5.0}/wechatbridge/codex.py +115 -2
  9. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.5.0}/wechatbridge/config.py +45 -2
  10. wechatbridge_cli-1.5.0/wechatbridge/dsh.py +478 -0
  11. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.5.0}/wechatbridge/grok.py +115 -6
  12. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.5.0}/wechatbridge/main.py +22 -4
  13. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.5.0}/wechatbridge/runner_common.py +48 -26
  14. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.5.0/wechatbridge_cli.egg-info}/PKG-INFO +31 -15
  15. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.5.0}/wechatbridge_cli.egg-info/SOURCES.txt +4 -0
  16. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.5.0}/LICENSE +0 -0
  17. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.5.0}/pyproject.toml +0 -0
  18. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.5.0}/setup.cfg +0 -0
  19. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.5.0}/tests/test_codex.py +0 -0
  20. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.5.0}/wechatbridge/__main__.py +0 -0
  21. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.5.0}/wechatbridge/agy.py +0 -0
  22. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.5.0}/wechatbridge/ilink.py +0 -0
  23. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.5.0}/wechatbridge/update_check.py +0 -0
  24. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.5.0}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
  25. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.5.0}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
  26. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.5.0}/wechatbridge_cli.egg-info/requires.txt +0 -0
  27. {wechatbridge_cli-1.4.8 → wechatbridge_cli-1.5.0}/wechatbridge_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wechatbridge-cli
3
- Version: 1.4.8
3
+ Version: 1.5.0
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
@@ -36,21 +36,21 @@ Dynamic: license-file
36
36
  ![license](https://img.shields.io/badge/license-MIT-blue.svg)
37
37
  ![python](https://img.shields.io/badge/python-3.10+-blue.svg)
38
38
 
39
- WeChatBridge connects a WeChat bot to agentic coding CLIs (Google's agy / Antigravity, xAI's Grok Build, or OpenAI's Codex). From WeChat you can send text, images, files, and voice-as-text to the active CLI, get replies back, and receive certain generated files over the WeChat CDN. Switch backends per user with `/backend` — no restart.
39
+ WeChatBridge connects a WeChat bot to agentic coding CLIs (Google's agy / Antigravity, xAI's Grok Build, OpenAI's Codex, or DeepSeek Harness' dsh). From WeChat you can send text, images, files, and voice-as-text to the active CLI, get replies back, and receive certain generated files over the WeChat CDN. Switch backends per user with `/backend` — no restart.
40
40
 
41
41
  ```
42
- WeChat (phone) ⇄ iLink bot API ⇄ WeChatBridge ⇄ agy / grok / codex CLI
42
+ WeChat (phone) ⇄ iLink bot API ⇄ WeChatBridge ⇄ agy / grok / codex / dsh CLI
43
43
  (this project) (runs tools)
44
44
  ```
45
45
 
46
- The bridge process stays up and long-polls iLink. For prompts that go to a CLI, it spawns one `agy` or `grok` child (`-p` single-turn) and exits that child when done — the child does not stay resident. Many slash commands (`/help`, `/backend`, `/persona`, …) are handled inside the bridge and never start a CLI. Only artifacts the bridge can detect under the user's allowed session paths are pushed back via CDN.
46
+ The bridge process stays up and long-polls iLink. For prompts that go to a CLI, it spawns one `agy` / `grok` / `codex` / `dsh` child (single-turn) and exits that child when done — the child does not stay resident. Many slash commands (`/help`, `/backend`, `/persona`, …) are handled inside the bridge and never start a CLI. Only artifacts the bridge can detect under the user's allowed session paths are pushed back via CDN.
47
47
 
48
48
  ## Features
49
49
 
50
- - Text, image, file, and voice (WeChat server-side transcription only) go to the **active** backend (`agy`, `grok`, or `codex`)
50
+ - Text, image, file, and voice (WeChat server-side transcription only) go to the **active** backend (`agy`, `grok`, `codex`, or `dsh`)
51
51
  - Detected CLI artifacts under the per-user allowed tree can be sent back (size-capped); not every file the CLI touches
52
52
  - Each WeChat user gets an isolated workspace; model / effort / mode are remembered **per backend**
53
- - Runtime backend switch: `/backend agy`, `/backend grok`, or `/backend codex` (clears that backend's continuation state — the agy/grok continuation flag and the codex `thread_id`/resume state — so the next CLI turn starts a fresh session; history files on disk are not wiped immediately)
53
+ - Runtime backend switch: `/backend agy`, `/backend grok`, `/backend codex`, or `/backend dsh` (clears that backend's continuation state — the agy/grok continuation flag and the codex `thread_id`/resume state — so the next CLI turn starts a fresh session; history files on disk are not wiped immediately)
54
54
  - Slash commands for model, session reset, persona, and more (see below)
55
55
  - Dangerous-prompt gate: a **keyword list** of concrete destructive patterns asks for confirmation before run
56
56
  - Sender whitelist (`WECHATBRIDGE_ALLOWED_SENDERS`; empty = allow all)
@@ -72,19 +72,28 @@ Default data paths expand from `~` (e.g. `~/.local/share/wechatbridge/<instance>
72
72
  - **agy** (default) — Google Antigravity CLI
73
73
  - **grok** — xAI Grok Build CLI
74
74
  - **codex** — OpenAI Codex CLI
75
+ - **dsh** — DeepSeek Harness CLI (one-shot `headless` profile)
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
+ Per-user switch: `/backend agy`, `/backend grok`, `/backend codex`, or `/backend dsh`. Each backend keeps its own model / effort / mode memory and persona file layout. Global default is `WECHATBRIDGE_BACKEND`.
78
+
79
+ ### dsh backend notes
80
+
81
+ - **Single-turn:** the `headless` profile always creates a fresh session per invocation (`session-<uuid>`), so every WeChat message starts a new dsh session. `/clear` / `/new` are accepted but are no-ops, and `/model`, `/fast`, `/planning`, `/persona`, `/add-dir` are not wired yet (they return a short "not supported" notice).
82
+ - Runs `dsh --profile headless -- <prompt>` with `cwd` = the per-user session directory (per-user workspace; model-created files land there and can be sent back via CDN).
83
+ - Image and file attachments are merged into the prompt text as `@/absolute/path` mentions. Verified in dsh v0.1.1-rc.2: `headless` passes the prompt text directly to the model without pre-reading or inlining mentions; the model receives system guidance on @-paths and may invoke file tools (e.g. `read`) if needed. The bridge only filters out-of-bounds mentions starting with absolute paths, `~`, or `file://` (`@/abs`, `@~/x`, `@file://`, replaced with `[blocked-path]`), which is best-effort prompt text filtering rather than a sandbox boundary.
84
+ - Auth / profiles are **machine-wide** (`~/.dsh`, same model as grok's machine-wide login): the child's `HOME` points at the per-user session dir, so the bridge always passes `DSH_HOME` explicitly. Set `WECHATBRIDGE_DSH_HOME` to configure a dedicated service home with automatic session retention cleanup; when unset, it reuses the host `~/.dsh` without automatic session cleanup (managed by the operator). `DSH_BIN_PATH`, `DSH_PROFILE`, and `DSH_TIMEOUT` are configurable.
85
+ - Status: implemented from the published `dsh` CLI contract (headless bundle source) plus a fake CLI in the test suite; final acceptance depends on a real `dsh login` + headless run.
77
86
 
78
87
  ### Grok backend notes
79
88
 
80
89
  - 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.
90
+ - 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
91
 
83
92
  ### Codex backend notes
84
93
 
85
94
  - 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
95
  - 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.
96
+ - 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
97
  - **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
98
 
90
99
  ## Prerequisites
@@ -93,7 +102,8 @@ Per-user switch: `/backend agy`, `/backend grok`, or `/backend codex`. Each back
93
102
  - **agy** on `PATH`, or set `AGY_BIN_PATH`
94
103
  - **and/or grok** on `PATH`, or set `GROK_BIN_PATH`
95
104
  - **and/or codex** on `PATH`, or set `CODEX_BIN_PATH`
96
- - Antigravity is Google's terminal agentic coding CLI (successor to Gemini CLI). Grok Build is xAI's counterpart; Codex is OpenAI's terminal agentic coding CLI.
105
+ - **and/or dsh** on `PATH`, or set `DSH_BIN_PATH` (DeepSeek Harness, `dsh login` required)
106
+ - Antigravity is Google's terminal agentic coding CLI (successor to Gemini CLI). Grok Build is xAI's counterpart; Codex is OpenAI's terminal agentic coding CLI; dsh is DeepSeek Harness' CLI.
97
107
  - A WeChat account with a [ClawBot / iLink](https://ilinkai.weixin.qq.com) bot (QR bind on first run)
98
108
  - Python 3.10+
99
109
 
@@ -164,10 +174,14 @@ Key variables (all have defaults):
164
174
  | `AGY_BIN_PATH` | `agy` | path to the agy binary |
165
175
  | `GROK_BIN_PATH` | `grok` | path to the grok binary |
166
176
  | `CODEX_BIN_PATH` | `codex` | path to the codex binary |
167
- | `WECHATBRIDGE_BACKEND` | `agy` | global default backend (`agy` / `grok` / `codex`; overridable per user via `/backend`) |
177
+ | `DSH_BIN_PATH` | `dsh` | path to the dsh binary |
178
+ | `DSH_PROFILE` | `headless` | dsh profile booted for one-shot tasks |
179
+ | `DSH_TIMEOUT` | `600` | dsh CLI run timeout in seconds |
180
+ | `WECHATBRIDGE_DSH_HOME` | _empty_ | explicit `DSH_HOME` passed to the dsh child. Explicitly set = dedicated home + auto session cleanup; unset = reuse host `~/.dsh` without auto cleanup |
181
+ | `WECHATBRIDGE_BACKEND` | `agy` | global default backend (`agy` / `grok` / `codex` / `dsh`; overridable per user via `/backend`) |
168
182
  | `WECHATBRIDGE_INSTANCE` | `default` | instance name; state / session / QR paths derive from it |
169
183
  | `WECHATBRIDGE_ALLOWED_SENDERS` | _empty_ | comma-separated WeChat IDs (empty = allow all) |
170
- | `AGY_TIMEOUT` | `600` | CLI run timeout in seconds (all three backends) |
184
+ | `AGY_TIMEOUT` | `600` | CLI run timeout in seconds (agy / grok / codex backends) |
171
185
  | `WECHATBRIDGE_MAX_OUTBOUND_BYTES` | `104857600` | max file size sent back to WeChat (100 MB) |
172
186
  | `WECHATBRIDGE_MAX_INBOUND_BYTES` | `20971520` | max inbound image/file after download (20 MB) |
173
187
  | `WECHATBRIDGE_MAX_CONCURRENT` | `4` | global concurrent process slots; same user serial (queue does not hold a slot); extras get a busy reply |
@@ -253,7 +267,7 @@ See [`deploy/wechatbridge-windows.md`](deploy/wechatbridge-windows.md).
253
267
  | Command | Action |
254
268
  |---|---|
255
269
  | `/help` | list supported commands for the active backend |
256
- | `/backend <agy\|grok\|codex>` | switch CLI backend for this WeChat user (on real change: clears that backend's continuation state — agy/grok flag and codex `thread_id`/resume — so the next turn starts a fresh session; history files may remain until retention cleanup) |
270
+ | `/backend <agy\|grok\|codex\|dsh>` | switch CLI backend for this WeChat user (on real change: clears that backend's continuation state — agy/grok flag and codex `thread_id`/resume — so the next turn starts a fresh session; history files may remain until retention cleanup) |
257
271
  | `/clear` or `/new` | drop continue flag so the next CLI turn is a new conversation (does not instantly delete history files) |
258
272
  | `/model <name>` | set model (all backends validate against a live list: agy/grok via CLI `models`; codex via `codex debug models` [then `--bundled`]; unknown name or list-fetch failure refuse and do not write prefs; see `/models`) |
259
273
  | `/models` | list models — agy/grok/codex all query the live CLI (codex: `debug models`; falls back to a built-in reference note only if the live list cannot be fetched) |
@@ -273,7 +287,7 @@ Other `/…` commands are either rejected (e.g. `/exit`), reported as unsupporte
273
287
  ## Ops & security (what the bridge actually enforces)
274
288
 
275
289
  - **Whitelist first.** Empty `WECHATBRIDGE_ALLOWED_SENDERS` means anyone who can message the bot can use it.
276
- - **Auto-approve CLIs.** agy runs with `--dangerously-skip-permissions`; grok with `--always-approve` (unless planning mode). Treat this as trusted-user tooling, not a multi-tenant sandbox.
290
+ - **Auto-approve CLIs.** agy runs with `--dangerously-skip-permissions`; grok with `--always-approve` (unless planning mode); dsh tools run without host path restrictions. Treat this as trusted-user tooling, not a multi-tenant sandbox.
277
291
  - **Danger gate is keyword-based**, not full intent understanding. Defaults target concrete patterns (`rm -rf /`, pipe-to-shell, `mkfs`, `format c:`, a few heavy Chinese phrases, …). Everyday wording like bare “delete” is **not** gated. Override list via `WECHATBRIDGE_CONFIRM_KEYWORDS`; approve with `WECHATBRIDGE_CONFIRM_TOKEN` (default `y`), TTL `WECHATBRIDGE_PENDING_TTL`.
278
292
  - **Inbound media** is size-capped (default 20 MB), streamed, and CDN hosts are allowlisted. Missing `aes_key` returns a clear error.
279
293
  - **Outbound artifacts** only leave the allowed per-user tree (agy: session scratch; grok: under session dir), after `realpath` checks, and only if under `WECHATBRIDGE_MAX_OUTBOUND_BYTES`.
@@ -285,8 +299,10 @@ Other `/…` commands are either rejected (e.g. `/exit`), reported as unsupporte
285
299
 
286
300
  ## Limitations
287
301
 
288
- - Not a standalone agent — requires agy and/or grok and/or codex.
302
+ - Not a standalone agent — requires agy and/or grok and/or codex and/or dsh.
289
303
  - The **codex** backend is not yet verified against a real Codex subscription/CLI; it is validated by source research, a JSONL fixture, and a fake CLI in tests. Treat it as community-tested until a real user confirms.
304
+ - The **dsh** backend is single-turn only (the `headless` profile always starts a fresh session) and has not yet been verified against a real `dsh login` + headless run; it is validated against the published headless bundle contract and a fake CLI in tests.
305
+ - dsh model/effort/mode/persona slash commands are not wired yet; `/model`, `/fast`, `/planning`, `/persona`, `/add-dir` return a "not supported" notice on the dsh backend.
290
306
  - Voice is WeChat speech-to-text only; no local ASR; empty transcript → “type instead”.
291
307
  - No video send/receive; no native WeChat voice-bubble replies (no silk encode).
292
308
  - One WeChat binding per process; multiple accounts need multiple instances (`WECHATBRIDGE_INSTANCE`).
@@ -5,21 +5,21 @@
5
5
  ![license](https://img.shields.io/badge/license-MIT-blue.svg)
6
6
  ![python](https://img.shields.io/badge/python-3.10+-blue.svg)
7
7
 
8
- WeChatBridge connects a WeChat bot to agentic coding CLIs (Google's agy / Antigravity, xAI's Grok Build, or OpenAI's Codex). From WeChat you can send text, images, files, and voice-as-text to the active CLI, get replies back, and receive certain generated files over the WeChat CDN. Switch backends per user with `/backend` — no restart.
8
+ WeChatBridge connects a WeChat bot to agentic coding CLIs (Google's agy / Antigravity, xAI's Grok Build, OpenAI's Codex, or DeepSeek Harness' dsh). From WeChat you can send text, images, files, and voice-as-text to the active CLI, get replies back, and receive certain generated files over the WeChat CDN. Switch backends per user with `/backend` — no restart.
9
9
 
10
10
  ```
11
- WeChat (phone) ⇄ iLink bot API ⇄ WeChatBridge ⇄ agy / grok / codex CLI
11
+ WeChat (phone) ⇄ iLink bot API ⇄ WeChatBridge ⇄ agy / grok / codex / dsh CLI
12
12
  (this project) (runs tools)
13
13
  ```
14
14
 
15
- The bridge process stays up and long-polls iLink. For prompts that go to a CLI, it spawns one `agy` or `grok` child (`-p` single-turn) and exits that child when done — the child does not stay resident. Many slash commands (`/help`, `/backend`, `/persona`, …) are handled inside the bridge and never start a CLI. Only artifacts the bridge can detect under the user's allowed session paths are pushed back via CDN.
15
+ The bridge process stays up and long-polls iLink. For prompts that go to a CLI, it spawns one `agy` / `grok` / `codex` / `dsh` child (single-turn) and exits that child when done — the child does not stay resident. Many slash commands (`/help`, `/backend`, `/persona`, …) are handled inside the bridge and never start a CLI. Only artifacts the bridge can detect under the user's allowed session paths are pushed back via CDN.
16
16
 
17
17
  ## Features
18
18
 
19
- - Text, image, file, and voice (WeChat server-side transcription only) go to the **active** backend (`agy`, `grok`, or `codex`)
19
+ - Text, image, file, and voice (WeChat server-side transcription only) go to the **active** backend (`agy`, `grok`, `codex`, or `dsh`)
20
20
  - Detected CLI artifacts under the per-user allowed tree can be sent back (size-capped); not every file the CLI touches
21
21
  - Each WeChat user gets an isolated workspace; model / effort / mode are remembered **per backend**
22
- - Runtime backend switch: `/backend agy`, `/backend grok`, or `/backend codex` (clears that backend's continuation state — the agy/grok continuation flag and the codex `thread_id`/resume state — so the next CLI turn starts a fresh session; history files on disk are not wiped immediately)
22
+ - Runtime backend switch: `/backend agy`, `/backend grok`, `/backend codex`, or `/backend dsh` (clears that backend's continuation state — the agy/grok continuation flag and the codex `thread_id`/resume state — so the next CLI turn starts a fresh session; history files on disk are not wiped immediately)
23
23
  - Slash commands for model, session reset, persona, and more (see below)
24
24
  - Dangerous-prompt gate: a **keyword list** of concrete destructive patterns asks for confirmation before run
25
25
  - Sender whitelist (`WECHATBRIDGE_ALLOWED_SENDERS`; empty = allow all)
@@ -41,19 +41,28 @@ Default data paths expand from `~` (e.g. `~/.local/share/wechatbridge/<instance>
41
41
  - **agy** (default) — Google Antigravity CLI
42
42
  - **grok** — xAI Grok Build CLI
43
43
  - **codex** — OpenAI Codex CLI
44
+ - **dsh** — DeepSeek Harness CLI (one-shot `headless` profile)
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
+ Per-user switch: `/backend agy`, `/backend grok`, `/backend codex`, or `/backend dsh`. Each backend keeps its own model / effort / mode memory and persona file layout. Global default is `WECHATBRIDGE_BACKEND`.
47
+
48
+ ### dsh backend notes
49
+
50
+ - **Single-turn:** the `headless` profile always creates a fresh session per invocation (`session-<uuid>`), so every WeChat message starts a new dsh session. `/clear` / `/new` are accepted but are no-ops, and `/model`, `/fast`, `/planning`, `/persona`, `/add-dir` are not wired yet (they return a short "not supported" notice).
51
+ - Runs `dsh --profile headless -- <prompt>` with `cwd` = the per-user session directory (per-user workspace; model-created files land there and can be sent back via CDN).
52
+ - Image and file attachments are merged into the prompt text as `@/absolute/path` mentions. Verified in dsh v0.1.1-rc.2: `headless` passes the prompt text directly to the model without pre-reading or inlining mentions; the model receives system guidance on @-paths and may invoke file tools (e.g. `read`) if needed. The bridge only filters out-of-bounds mentions starting with absolute paths, `~`, or `file://` (`@/abs`, `@~/x`, `@file://`, replaced with `[blocked-path]`), which is best-effort prompt text filtering rather than a sandbox boundary.
53
+ - Auth / profiles are **machine-wide** (`~/.dsh`, same model as grok's machine-wide login): the child's `HOME` points at the per-user session dir, so the bridge always passes `DSH_HOME` explicitly. Set `WECHATBRIDGE_DSH_HOME` to configure a dedicated service home with automatic session retention cleanup; when unset, it reuses the host `~/.dsh` without automatic session cleanup (managed by the operator). `DSH_BIN_PATH`, `DSH_PROFILE`, and `DSH_TIMEOUT` are configurable.
54
+ - Status: implemented from the published `dsh` CLI contract (headless bundle source) plus a fake CLI in the test suite; final acceptance depends on a real `dsh login` + headless run.
46
55
 
47
56
  ### Grok backend notes
48
57
 
49
58
  - 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.
59
+ - 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
60
 
52
61
  ### Codex backend notes
53
62
 
54
63
  - 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
64
  - 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.
65
+ - 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
66
  - **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
67
 
59
68
  ## Prerequisites
@@ -62,7 +71,8 @@ Per-user switch: `/backend agy`, `/backend grok`, or `/backend codex`. Each back
62
71
  - **agy** on `PATH`, or set `AGY_BIN_PATH`
63
72
  - **and/or grok** on `PATH`, or set `GROK_BIN_PATH`
64
73
  - **and/or codex** on `PATH`, or set `CODEX_BIN_PATH`
65
- - Antigravity is Google's terminal agentic coding CLI (successor to Gemini CLI). Grok Build is xAI's counterpart; Codex is OpenAI's terminal agentic coding CLI.
74
+ - **and/or dsh** on `PATH`, or set `DSH_BIN_PATH` (DeepSeek Harness, `dsh login` required)
75
+ - Antigravity is Google's terminal agentic coding CLI (successor to Gemini CLI). Grok Build is xAI's counterpart; Codex is OpenAI's terminal agentic coding CLI; dsh is DeepSeek Harness' CLI.
66
76
  - A WeChat account with a [ClawBot / iLink](https://ilinkai.weixin.qq.com) bot (QR bind on first run)
67
77
  - Python 3.10+
68
78
 
@@ -133,10 +143,14 @@ Key variables (all have defaults):
133
143
  | `AGY_BIN_PATH` | `agy` | path to the agy binary |
134
144
  | `GROK_BIN_PATH` | `grok` | path to the grok binary |
135
145
  | `CODEX_BIN_PATH` | `codex` | path to the codex binary |
136
- | `WECHATBRIDGE_BACKEND` | `agy` | global default backend (`agy` / `grok` / `codex`; overridable per user via `/backend`) |
146
+ | `DSH_BIN_PATH` | `dsh` | path to the dsh binary |
147
+ | `DSH_PROFILE` | `headless` | dsh profile booted for one-shot tasks |
148
+ | `DSH_TIMEOUT` | `600` | dsh CLI run timeout in seconds |
149
+ | `WECHATBRIDGE_DSH_HOME` | _empty_ | explicit `DSH_HOME` passed to the dsh child. Explicitly set = dedicated home + auto session cleanup; unset = reuse host `~/.dsh` without auto cleanup |
150
+ | `WECHATBRIDGE_BACKEND` | `agy` | global default backend (`agy` / `grok` / `codex` / `dsh`; overridable per user via `/backend`) |
137
151
  | `WECHATBRIDGE_INSTANCE` | `default` | instance name; state / session / QR paths derive from it |
138
152
  | `WECHATBRIDGE_ALLOWED_SENDERS` | _empty_ | comma-separated WeChat IDs (empty = allow all) |
139
- | `AGY_TIMEOUT` | `600` | CLI run timeout in seconds (all three backends) |
153
+ | `AGY_TIMEOUT` | `600` | CLI run timeout in seconds (agy / grok / codex backends) |
140
154
  | `WECHATBRIDGE_MAX_OUTBOUND_BYTES` | `104857600` | max file size sent back to WeChat (100 MB) |
141
155
  | `WECHATBRIDGE_MAX_INBOUND_BYTES` | `20971520` | max inbound image/file after download (20 MB) |
142
156
  | `WECHATBRIDGE_MAX_CONCURRENT` | `4` | global concurrent process slots; same user serial (queue does not hold a slot); extras get a busy reply |
@@ -222,7 +236,7 @@ See [`deploy/wechatbridge-windows.md`](deploy/wechatbridge-windows.md).
222
236
  | Command | Action |
223
237
  |---|---|
224
238
  | `/help` | list supported commands for the active backend |
225
- | `/backend <agy\|grok\|codex>` | switch CLI backend for this WeChat user (on real change: clears that backend's continuation state — agy/grok flag and codex `thread_id`/resume — so the next turn starts a fresh session; history files may remain until retention cleanup) |
239
+ | `/backend <agy\|grok\|codex\|dsh>` | switch CLI backend for this WeChat user (on real change: clears that backend's continuation state — agy/grok flag and codex `thread_id`/resume — so the next turn starts a fresh session; history files may remain until retention cleanup) |
226
240
  | `/clear` or `/new` | drop continue flag so the next CLI turn is a new conversation (does not instantly delete history files) |
227
241
  | `/model <name>` | set model (all backends validate against a live list: agy/grok via CLI `models`; codex via `codex debug models` [then `--bundled`]; unknown name or list-fetch failure refuse and do not write prefs; see `/models`) |
228
242
  | `/models` | list models — agy/grok/codex all query the live CLI (codex: `debug models`; falls back to a built-in reference note only if the live list cannot be fetched) |
@@ -242,7 +256,7 @@ Other `/…` commands are either rejected (e.g. `/exit`), reported as unsupporte
242
256
  ## Ops & security (what the bridge actually enforces)
243
257
 
244
258
  - **Whitelist first.** Empty `WECHATBRIDGE_ALLOWED_SENDERS` means anyone who can message the bot can use it.
245
- - **Auto-approve CLIs.** agy runs with `--dangerously-skip-permissions`; grok with `--always-approve` (unless planning mode). Treat this as trusted-user tooling, not a multi-tenant sandbox.
259
+ - **Auto-approve CLIs.** agy runs with `--dangerously-skip-permissions`; grok with `--always-approve` (unless planning mode); dsh tools run without host path restrictions. Treat this as trusted-user tooling, not a multi-tenant sandbox.
246
260
  - **Danger gate is keyword-based**, not full intent understanding. Defaults target concrete patterns (`rm -rf /`, pipe-to-shell, `mkfs`, `format c:`, a few heavy Chinese phrases, …). Everyday wording like bare “delete” is **not** gated. Override list via `WECHATBRIDGE_CONFIRM_KEYWORDS`; approve with `WECHATBRIDGE_CONFIRM_TOKEN` (default `y`), TTL `WECHATBRIDGE_PENDING_TTL`.
247
261
  - **Inbound media** is size-capped (default 20 MB), streamed, and CDN hosts are allowlisted. Missing `aes_key` returns a clear error.
248
262
  - **Outbound artifacts** only leave the allowed per-user tree (agy: session scratch; grok: under session dir), after `realpath` checks, and only if under `WECHATBRIDGE_MAX_OUTBOUND_BYTES`.
@@ -254,8 +268,10 @@ Other `/…` commands are either rejected (e.g. `/exit`), reported as unsupporte
254
268
 
255
269
  ## Limitations
256
270
 
257
- - Not a standalone agent — requires agy and/or grok and/or codex.
271
+ - Not a standalone agent — requires agy and/or grok and/or codex and/or dsh.
258
272
  - The **codex** backend is not yet verified against a real Codex subscription/CLI; it is validated by source research, a JSONL fixture, and a fake CLI in tests. Treat it as community-tested until a real user confirms.
273
+ - The **dsh** backend is single-turn only (the `headless` profile always starts a fresh session) and has not yet been verified against a real `dsh login` + headless run; it is validated against the published headless bundle contract and a fake CLI in tests.
274
+ - dsh model/effort/mode/persona slash commands are not wired yet; `/model`, `/fast`, `/planning`, `/persona`, `/add-dir` return a "not supported" notice on the dsh backend.
259
275
  - Voice is WeChat speech-to-text only; no local ASR; empty transcript → “type instead”.
260
276
  - No video send/receive; no native WeChat voice-bubble replies (no silk encode).
261
277
  - One WeChat binding per process; multiple accounts need multiple instances (`WECHATBRIDGE_INSTANCE`).
@@ -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()