wechatbridge-cli 1.7.0__tar.gz → 1.7.1__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.7.0 → wechatbridge_cli-1.7.1}/PKG-INFO +1 -1
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/tests/test_hardening.py +256 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge/__init__.py +1 -1
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge/agy.py +115 -27
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge/codex.py +28 -4
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge/dsh.py +13 -70
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge/grok.py +28 -3
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge/ilink.py +40 -6
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge/main.py +57 -17
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge/price.py +96 -22
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge/runner_common.py +258 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge_cli.egg-info/PKG-INFO +1 -1
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/LICENSE +0 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/README.md +0 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/pyproject.toml +0 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/setup.cfg +0 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/tests/test_codex.py +0 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/tests/test_codex_auth.py +0 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/tests/test_dsh.py +0 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/tests/test_grok_auth.py +0 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge/__main__.py +0 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge/config.py +0 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge/update_check.py +0 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge_cli.egg-info/SOURCES.txt +0 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge_cli.egg-info/requires.txt +0 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.1}/wechatbridge_cli.egg-info/top_level.txt +0 -0
|
@@ -2983,5 +2983,261 @@ class TestAgyStreamRetry(unittest.IsolatedAsyncioTestCase):
|
|
|
2983
2983
|
mock_sleep.assert_not_awaited()
|
|
2984
2984
|
|
|
2985
2985
|
|
|
2986
|
+
class TestAuditHardeningFixes(unittest.IsolatedAsyncioTestCase):
|
|
2987
|
+
"""Regression tests for deep-audit security and correctness fixes."""
|
|
2988
|
+
|
|
2989
|
+
def setUp(self):
|
|
2990
|
+
from wechatbridge.config import config as cfg
|
|
2991
|
+
self._cfg = cfg
|
|
2992
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
2993
|
+
self.session_base = self._tmp.name
|
|
2994
|
+
self._orig_base = cfg.session_base_dir
|
|
2995
|
+
self._orig_backend = cfg.backend
|
|
2996
|
+
cfg.session_base_dir = self.session_base
|
|
2997
|
+
|
|
2998
|
+
def tearDown(self):
|
|
2999
|
+
self._cfg.session_base_dir = self._orig_base
|
|
3000
|
+
self._cfg.backend = self._orig_backend
|
|
3001
|
+
self._tmp.cleanup()
|
|
3002
|
+
|
|
3003
|
+
async def test_agy_send_artifacts_allows_session_dir_and_blocks_sensitive_or_hardlink(self):
|
|
3004
|
+
from wechatbridge import main as main_mod
|
|
3005
|
+
from wechatbridge.runner_common import ensure_session_dir
|
|
3006
|
+
|
|
3007
|
+
self._cfg.backend = "agy"
|
|
3008
|
+
uid = "u-agy-sec"
|
|
3009
|
+
sdir = ensure_session_dir(uid)
|
|
3010
|
+
|
|
3011
|
+
# Normal artifact directly in session_dir (should be allowed)
|
|
3012
|
+
good_file = os.path.join(sdir, "report.pdf")
|
|
3013
|
+
with open(good_file, "wb") as f:
|
|
3014
|
+
f.write(b"%PDF-1.4 ok")
|
|
3015
|
+
|
|
3016
|
+
# Normal artifact in .gemini/antigravity-cli/scratch (should be allowed)
|
|
3017
|
+
scratch_dir = os.path.join(sdir, ".gemini", "antigravity-cli", "scratch")
|
|
3018
|
+
os.makedirs(scratch_dir, exist_ok=True)
|
|
3019
|
+
scratch_file = os.path.join(scratch_dir, "chart.png")
|
|
3020
|
+
with open(scratch_file, "wb") as f:
|
|
3021
|
+
f.write(b"PNGDATA")
|
|
3022
|
+
|
|
3023
|
+
# Sensitive files inside session_dir (must be blocked)
|
|
3024
|
+
prefs_file = os.path.join(sdir, "prefs.json")
|
|
3025
|
+
with open(prefs_file, "w") as f:
|
|
3026
|
+
f.write('{"model": "gemini-4-argon"}')
|
|
3027
|
+
gemini_dir = os.path.join(sdir, ".gemini")
|
|
3028
|
+
os.makedirs(gemini_dir, exist_ok=True)
|
|
3029
|
+
token_file = os.path.join(gemini_dir, "antigravity-oauth-token")
|
|
3030
|
+
with open(token_file, "w") as f:
|
|
3031
|
+
f.write("secret-oauth-token")
|
|
3032
|
+
|
|
3033
|
+
# Hardlink inside session_dir pointing to external file (must be blocked)
|
|
3034
|
+
ext_file = os.path.join(self.session_base, "external_secret.txt")
|
|
3035
|
+
with open(ext_file, "w") as f:
|
|
3036
|
+
f.write("top-secret")
|
|
3037
|
+
hardlink_file = os.path.join(sdir, "linked.txt")
|
|
3038
|
+
os.link(ext_file, hardlink_file)
|
|
3039
|
+
|
|
3040
|
+
client = mock.MagicMock()
|
|
3041
|
+
client.state.baseurl = "https://ilink"
|
|
3042
|
+
client.state.bot_token = "tok"
|
|
3043
|
+
client.send_media = AsyncMock(return_value=True)
|
|
3044
|
+
client.send_message = AsyncMock(return_value=True)
|
|
3045
|
+
|
|
3046
|
+
await main_mod.send_artifacts_back(
|
|
3047
|
+
artifacts=[
|
|
3048
|
+
("report.pdf", good_file),
|
|
3049
|
+
("chart.png", scratch_file),
|
|
3050
|
+
("prefs.json", prefs_file),
|
|
3051
|
+
("antigravity-oauth-token", token_file),
|
|
3052
|
+
("linked.txt", hardlink_file),
|
|
3053
|
+
],
|
|
3054
|
+
client=client,
|
|
3055
|
+
from_user=uid,
|
|
3056
|
+
context_token="ctx",
|
|
3057
|
+
)
|
|
3058
|
+
|
|
3059
|
+
sent_paths = [c.kwargs["path"] for c in client.send_media.await_args_list]
|
|
3060
|
+
self.assertEqual(
|
|
3061
|
+
sent_paths,
|
|
3062
|
+
[os.path.realpath(good_file), os.path.realpath(scratch_file)],
|
|
3063
|
+
)
|
|
3064
|
+
|
|
3065
|
+
def test_agy_extract_artifacts_balanced_parens_and_model_tsv_parsing(self):
|
|
3066
|
+
from wechatbridge import agy as agy_mod
|
|
3067
|
+
|
|
3068
|
+
text = (
|
|
3069
|
+
"已生成 [report (1).pdf](file:///tmp/sess/report%20(1).pdf) "
|
|
3070
|
+
"和 [v2 [final].docx](file:///tmp/sess/v2%20[final].docx)。"
|
|
3071
|
+
)
|
|
3072
|
+
arts = agy_mod.extract_artifacts(text)
|
|
3073
|
+
self.assertEqual(
|
|
3074
|
+
arts,
|
|
3075
|
+
[
|
|
3076
|
+
("report (1).pdf", "/tmp/sess/report (1).pdf"),
|
|
3077
|
+
("v2 [final].docx", "/tmp/sess/v2 [final].docx"),
|
|
3078
|
+
],
|
|
3079
|
+
)
|
|
3080
|
+
|
|
3081
|
+
tsv_models = (
|
|
3082
|
+
"gemini-4-argon\tGemini 3.1 Pro (High)\n"
|
|
3083
|
+
"claude-opus-4-6-thinking\tClaude Opus 4.6 (Thinking)\n"
|
|
3084
|
+
)
|
|
3085
|
+
self.assertEqual(
|
|
3086
|
+
agy_mod._parse_agy_model_ids(tsv_models),
|
|
3087
|
+
["gemini-4-argon", "claude-opus-4-6-thinking"],
|
|
3088
|
+
)
|
|
3089
|
+
|
|
3090
|
+
async def test_planning_and_fast_toggle_and_clear_resets_mode(self):
|
|
3091
|
+
from wechatbridge import agy as agy_mod
|
|
3092
|
+
from wechatbridge.runner_common import load_prefs
|
|
3093
|
+
|
|
3094
|
+
uid = "u-toggle"
|
|
3095
|
+
r1 = await agy_mod.handle_slash_command("/planning", uid)
|
|
3096
|
+
self.assertIn("已开启规划模式", r1)
|
|
3097
|
+
self.assertEqual(load_prefs(uid).get("mode"), "plan")
|
|
3098
|
+
|
|
3099
|
+
# Calling /planning again toggles it off
|
|
3100
|
+
r2 = await agy_mod.handle_slash_command("/planning", uid)
|
|
3101
|
+
self.assertIn("已关闭规划模式", r2)
|
|
3102
|
+
self.assertEqual(load_prefs(uid).get("mode"), "")
|
|
3103
|
+
|
|
3104
|
+
# Enable again and verify /clear resets sticky mode
|
|
3105
|
+
await agy_mod.handle_slash_command("/planning", uid)
|
|
3106
|
+
self.assertEqual(load_prefs(uid).get("mode"), "plan")
|
|
3107
|
+
await agy_mod.handle_slash_command("/clear", uid)
|
|
3108
|
+
self.assertEqual(load_prefs(uid).get("mode"), "")
|
|
3109
|
+
|
|
3110
|
+
# /fast off
|
|
3111
|
+
await agy_mod.handle_slash_command("/fast", uid)
|
|
3112
|
+
self.assertEqual(load_prefs(uid).get("effort"), "low")
|
|
3113
|
+
r_fast_off = await agy_mod.handle_slash_command("/fast off", uid)
|
|
3114
|
+
self.assertIn("已关闭快速模式", r_fast_off)
|
|
3115
|
+
self.assertEqual(load_prefs(uid).get("effort"), "")
|
|
3116
|
+
|
|
3117
|
+
def test_sanitize_prompt_at_paths_allows_session_and_add_dirs_blocks_external(self):
|
|
3118
|
+
from wechatbridge.runner_common import _sanitize_prompt_at_paths, ensure_session_dir
|
|
3119
|
+
|
|
3120
|
+
sdir = ensure_session_dir("u-at")
|
|
3121
|
+
extra_dir = os.path.join(self.session_base, "allowed_extra")
|
|
3122
|
+
os.makedirs(extra_dir, exist_ok=True)
|
|
3123
|
+
prompt = (
|
|
3124
|
+
f"请看 @{sdir}/img.png 和 @{extra_dir}/doc.md "
|
|
3125
|
+
"不要读 @/etc/passwd 或 @../../etc/shadow"
|
|
3126
|
+
)
|
|
3127
|
+
sanitized = _sanitize_prompt_at_paths(prompt, sdir, extra_roots=[extra_dir])
|
|
3128
|
+
self.assertIn(f"@{sdir}/img.png", sanitized)
|
|
3129
|
+
self.assertIn(f"@{extra_dir}/doc.md", sanitized)
|
|
3130
|
+
self.assertNotIn("/etc/passwd", sanitized)
|
|
3131
|
+
self.assertNotIn("/etc/shadow", sanitized)
|
|
3132
|
+
self.assertEqual(sanitized.count("[blocked-path]"), 2)
|
|
3133
|
+
|
|
3134
|
+
async def test_ilink_get_updates_raises_on_5xx_and_encrypt_roundtrip(self):
|
|
3135
|
+
import httpx
|
|
3136
|
+
from wechatbridge.ilink import ILinkClient, _encrypt_aes_ecb, _decrypt_aes_ecb
|
|
3137
|
+
|
|
3138
|
+
key = os.urandom(16)
|
|
3139
|
+
for length in (0, 1, 15, 16, 17, 32, 100):
|
|
3140
|
+
raw = os.urandom(length)
|
|
3141
|
+
enc = _encrypt_aes_ecb(raw, key)
|
|
3142
|
+
self.assertEqual(_decrypt_aes_ecb(enc, key), raw)
|
|
3143
|
+
|
|
3144
|
+
client = ILinkClient()
|
|
3145
|
+
try:
|
|
3146
|
+
req = httpx.Request("POST", "https://ilink/ilink/bot/getupdates")
|
|
3147
|
+
resp_502 = httpx.Response(502, request=req)
|
|
3148
|
+
client.http_client.post = AsyncMock(
|
|
3149
|
+
side_effect=httpx.HTTPStatusError("502 Bad Gateway", request=req, response=resp_502)
|
|
3150
|
+
)
|
|
3151
|
+
client.state.clear = mock.MagicMock()
|
|
3152
|
+
with self.assertRaises(httpx.HTTPStatusError):
|
|
3153
|
+
await client.get_updates("buf1", "https://ilink", "tok")
|
|
3154
|
+
client.state.clear.assert_not_called()
|
|
3155
|
+
finally:
|
|
3156
|
+
await client.close()
|
|
3157
|
+
|
|
3158
|
+
async def test_price_http_error_does_not_leak_url_or_appkey(self):
|
|
3159
|
+
import httpx
|
|
3160
|
+
from wechatbridge import price as price_mod
|
|
3161
|
+
|
|
3162
|
+
req = httpx.Request(
|
|
3163
|
+
"GET",
|
|
3164
|
+
"https://openapi.dataoke.com/api/goods/get-goods-details?appKey=SECRET_DT_KEY_123&id=1",
|
|
3165
|
+
)
|
|
3166
|
+
err = httpx.ConnectError(
|
|
3167
|
+
"Connection failed for https://openapi.dataoke.com/api?appKey=SECRET_DT_KEY_123",
|
|
3168
|
+
request=req,
|
|
3169
|
+
)
|
|
3170
|
+
with mock.patch.object(price_mod, "dt_configured", return_value=True), \
|
|
3171
|
+
mock.patch.object(price_mod, "query_tb_item", side_effect=err):
|
|
3172
|
+
reply = await price_mod.handle_price_query({"kind": "tb_item", "item_id": "1"})
|
|
3173
|
+
self.assertNotIn("SECRET_DT_KEY_123", reply)
|
|
3174
|
+
self.assertIn("比价接口请求失败", reply)
|
|
3175
|
+
|
|
3176
|
+
with self.assertRaises(ValueError):
|
|
3177
|
+
price_mod._resolve_short_link("http://127.0.0.1:8080/admin")
|
|
3178
|
+
self.assertFalse(price_mod._is_allowed_short_url("https://evil.u.jd.com/abc"))
|
|
3179
|
+
self.assertFalse(price_mod._is_allowed_short_url("https://a.3.cn/xyz"))
|
|
3180
|
+
self.assertTrue(price_mod._is_allowed_short_url("https://u.jd.com/abc"))
|
|
3181
|
+
|
|
3182
|
+
def test_ensure_user_gemini_refreshes_stale_oauth_token_and_blocks_copied_secrets(self):
|
|
3183
|
+
from wechatbridge import agy as agy_mod
|
|
3184
|
+
from wechatbridge.runner_common import is_sensitive_artifact_path, _sanitize_prompt_at_paths
|
|
3185
|
+
|
|
3186
|
+
fake_host_home = os.path.join(self.session_base, "host_home")
|
|
3187
|
+
host_agy = os.path.join(fake_host_home, ".gemini", "antigravity-cli")
|
|
3188
|
+
os.makedirs(host_agy, exist_ok=True)
|
|
3189
|
+
src_token = os.path.join(host_agy, "antigravity-oauth-token")
|
|
3190
|
+
with open(src_token, "w") as f:
|
|
3191
|
+
f.write("old-token-value-111111")
|
|
3192
|
+
|
|
3193
|
+
def _fake_expanduser(p: str) -> str:
|
|
3194
|
+
if p.startswith("~/"):
|
|
3195
|
+
return os.path.join(fake_host_home, p[2:])
|
|
3196
|
+
return p
|
|
3197
|
+
|
|
3198
|
+
with mock.patch("os.path.expanduser", side_effect=_fake_expanduser):
|
|
3199
|
+
sdir = agy_mod.ensure_user_gemini("u-tok-refresh")
|
|
3200
|
+
dst_token = os.path.join(sdir, ".gemini", "antigravity-cli", "antigravity-oauth-token")
|
|
3201
|
+
with open(dst_token) as f:
|
|
3202
|
+
self.assertEqual(f.read(), "old-token-value-111111")
|
|
3203
|
+
|
|
3204
|
+
# Make dst older and update src token
|
|
3205
|
+
os.utime(dst_token, (1000.0, 1000.0))
|
|
3206
|
+
with open(src_token, "w") as f:
|
|
3207
|
+
f.write("refreshed-token-value-222222")
|
|
3208
|
+
os.utime(src_token, (2000.0, 2000.0))
|
|
3209
|
+
|
|
3210
|
+
agy_mod.ensure_user_gemini("u-tok-refresh")
|
|
3211
|
+
with open(dst_token) as f:
|
|
3212
|
+
self.assertEqual(f.read(), "refreshed-token-value-222222")
|
|
3213
|
+
|
|
3214
|
+
# Renamed / copied credentials must still be blocked by is_sensitive_artifact_path
|
|
3215
|
+
copied_secret = os.path.join(sdir, "token.copy")
|
|
3216
|
+
with open(copied_secret, "w") as f:
|
|
3217
|
+
f.write("refreshed-token-value-222222")
|
|
3218
|
+
self.assertTrue(is_sensitive_artifact_path(copied_secret, sdir))
|
|
3219
|
+
|
|
3220
|
+
auth_bak = os.path.join(sdir, "auth.json.bak")
|
|
3221
|
+
with open(auth_bak, "w") as f:
|
|
3222
|
+
f.write("{}")
|
|
3223
|
+
self.assertTrue(is_sensitive_artifact_path(auth_bak, sdir))
|
|
3224
|
+
|
|
3225
|
+
scratch_settings = os.path.join(sdir, ".gemini", "antigravity-cli", "scratch", "settings.json")
|
|
3226
|
+
os.makedirs(os.path.dirname(scratch_settings), exist_ok=True)
|
|
3227
|
+
with open(scratch_settings, "w") as f:
|
|
3228
|
+
f.write("{}")
|
|
3229
|
+
self.assertTrue(is_sensitive_artifact_path(scratch_settings, sdir))
|
|
3230
|
+
|
|
3231
|
+
# @<path> sanitization for bare sensitive names, URL-encoded traversal, and URLs
|
|
3232
|
+
p_out = _sanitize_prompt_at_paths(
|
|
3233
|
+
"read @prefs.json @auth.json.bak @..%2f..%2fetc%2fpasswd @https://evil.com/a",
|
|
3234
|
+
sdir,
|
|
3235
|
+
)
|
|
3236
|
+
self.assertEqual(
|
|
3237
|
+
p_out,
|
|
3238
|
+
"read [blocked-path] [blocked-path] [blocked-path] [blocked-path]",
|
|
3239
|
+
)
|
|
3240
|
+
|
|
3241
|
+
|
|
2986
3242
|
if __name__ == "__main__":
|
|
2987
3243
|
unittest.main()
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""WeChatBridge — bridge WeChat messages to agy, Grok Build, or Codex CLIs."""
|
|
2
|
-
__version__ = "1.7.
|
|
2
|
+
__version__ = "1.7.1"
|
|
@@ -10,13 +10,16 @@ import re
|
|
|
10
10
|
import shutil
|
|
11
11
|
import signal
|
|
12
12
|
import sys
|
|
13
|
+
import tempfile
|
|
13
14
|
import time
|
|
14
15
|
from urllib.parse import unquote
|
|
15
16
|
|
|
16
17
|
from .config import config
|
|
17
18
|
from .runner_common import (
|
|
19
|
+
MAX_ADD_DIRS,
|
|
20
|
+
_sanitize_prompt_at_paths,
|
|
18
21
|
sanitize_user_id, get_session_dir, ensure_session_dir, is_first_message, mark_initialized, clear_initialized,
|
|
19
|
-
clean_output, load_prefs, save_prefs, is_dangerous, parse_model_effort,
|
|
22
|
+
clean_output, load_prefs, save_prefs, is_dangerous, is_sensitive_artifact_path, parse_model_effort,
|
|
20
23
|
sanitize_env, terminate_process, update_active_prefs,
|
|
21
24
|
format_error, format_cli_error, _classify_cli_error, is_bridge_formatted_reply, EMPTY_REPLY,
|
|
22
25
|
ANSI_RE, HTML_TAG_RE, validate_add_dir,
|
|
@@ -27,22 +30,26 @@ logger = logging.getLogger("agy_runner")
|
|
|
27
30
|
# execve 单参数上限(Linux MAX_ARG_STRLEN = 128KB),留安全余量
|
|
28
31
|
_MAX_ARG_BYTES = 120 * 1024
|
|
29
32
|
|
|
33
|
+
# Matches markdown [name](file:///path) while supporting one level of balanced
|
|
34
|
+
# brackets in display name and one level of balanced parentheses in path (e.g.
|
|
35
|
+
# "report (1).pdf"), without greedily spanning across multiple links on a line.
|
|
36
|
+
_FILE_LINK_RE = re.compile(
|
|
37
|
+
r"\[((?:[^\[\]]+|\[[^\[\]]*\])+)\]\(file:///((?:[^()\s]+|\([^()\s]*\))+)\)"
|
|
38
|
+
)
|
|
39
|
+
|
|
30
40
|
|
|
31
41
|
def extract_artifacts(text: str) -> list[tuple[str, str]]:
|
|
32
42
|
"""Extract (name, absolute_path) tuples from markdown file:/// links.
|
|
33
43
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
Paths and display names are URL-decoded (``urllib.parse.unquote``) so
|
|
38
|
-
percent-encoded spaces / CJK (e.g. ``my%20report.pdf``, ``%E6%8A%A5%E5%91%8A.pdf``)
|
|
39
|
-
resolve to real filesystem paths.
|
|
44
|
+
Supports balanced parentheses in filenames (e.g. ``report (1).pdf``) and
|
|
45
|
+
URL-decodes (``urllib.parse.unquote``) percent-encoded spaces / CJK.
|
|
46
|
+
Returns a deduplicated, order-preserved list.
|
|
40
47
|
"""
|
|
41
48
|
if not text:
|
|
42
49
|
return []
|
|
43
50
|
seen = set()
|
|
44
51
|
result = []
|
|
45
|
-
for match in
|
|
52
|
+
for match in _FILE_LINK_RE.finditer(text):
|
|
46
53
|
name = unquote(match.group(1).split("#")[0])
|
|
47
54
|
path_part = unquote(match.group(2).split("#")[0])
|
|
48
55
|
abs_path = path_part if path_part.startswith("/") else "/" + path_part
|
|
@@ -88,14 +95,38 @@ def ensure_user_gemini(user_id: str) -> str:
|
|
|
88
95
|
except OSError:
|
|
89
96
|
pass
|
|
90
97
|
|
|
91
|
-
# Copy global auth token
|
|
98
|
+
# Copy or refresh global auth token when missing or older than host token
|
|
92
99
|
# agy standard auth token path, managed by agy CLI
|
|
93
100
|
token_src = os.path.expanduser("~/.gemini/antigravity-cli/antigravity-oauth-token")
|
|
94
101
|
token_dst = os.path.join(antigravity_dir, "antigravity-oauth-token")
|
|
95
|
-
if
|
|
102
|
+
if os.path.exists(token_src):
|
|
96
103
|
try:
|
|
97
|
-
|
|
98
|
-
os.
|
|
104
|
+
src_stat = os.stat(token_src)
|
|
105
|
+
need_copy = not os.path.exists(token_dst)
|
|
106
|
+
if not need_copy and src_stat.st_size > 0:
|
|
107
|
+
try:
|
|
108
|
+
dst_stat = os.stat(token_dst)
|
|
109
|
+
if src_stat.st_mtime > dst_stat.st_mtime or dst_stat.st_size == 0:
|
|
110
|
+
need_copy = True
|
|
111
|
+
except OSError:
|
|
112
|
+
need_copy = True
|
|
113
|
+
if need_copy and src_stat.st_size > 0:
|
|
114
|
+
fd, tmp_token = tempfile.mkstemp(
|
|
115
|
+
dir=antigravity_dir, prefix=".oauth-token.", suffix=".tmp"
|
|
116
|
+
)
|
|
117
|
+
try:
|
|
118
|
+
with os.fdopen(fd, "wb") as fout:
|
|
119
|
+
with open(token_src, "rb") as fin:
|
|
120
|
+
shutil.copyfileobj(fin, fout)
|
|
121
|
+
os.chmod(tmp_token, 0o600)
|
|
122
|
+
os.replace(tmp_token, token_dst)
|
|
123
|
+
except OSError:
|
|
124
|
+
if os.path.exists(tmp_token):
|
|
125
|
+
try:
|
|
126
|
+
os.unlink(tmp_token)
|
|
127
|
+
except OSError:
|
|
128
|
+
pass
|
|
129
|
+
raise
|
|
99
130
|
except OSError as e:
|
|
100
131
|
logger.warning("Failed to copy auth token for %s: %s", user_id, e)
|
|
101
132
|
|
|
@@ -178,15 +209,21 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
|
|
|
178
209
|
cmd += ["--effort", effort]
|
|
179
210
|
if prefs.get("mode"):
|
|
180
211
|
cmd += ["--mode", prefs["mode"]]
|
|
181
|
-
|
|
212
|
+
valid_add_dirs = []
|
|
213
|
+
for d in (prefs.get("add_dirs", []) or [])[:MAX_ADD_DIRS]:
|
|
182
214
|
if not d:
|
|
183
215
|
continue
|
|
184
216
|
ok, resolved = validate_add_dir(d, user_id)
|
|
185
217
|
if ok:
|
|
218
|
+
valid_add_dirs.append(resolved)
|
|
186
219
|
cmd += ["--add-dir", resolved]
|
|
187
220
|
else:
|
|
188
221
|
logger.warning("Skipping disallowed add_dir for %s: %s (%s)", user_id, d, resolved)
|
|
189
222
|
|
|
223
|
+
safe_prompt = _sanitize_prompt_at_paths(
|
|
224
|
+
prompt, session_dir, extra_roots=valid_add_dirs
|
|
225
|
+
)
|
|
226
|
+
|
|
190
227
|
if first:
|
|
191
228
|
logger.info(
|
|
192
229
|
"First message for user %s, running: agy -p ...", user_id
|
|
@@ -198,7 +235,7 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
|
|
|
198
235
|
user_id,
|
|
199
236
|
)
|
|
200
237
|
|
|
201
|
-
cmd += ["-p",
|
|
238
|
+
cmd += ["-p", safe_prompt]
|
|
202
239
|
|
|
203
240
|
process = None
|
|
204
241
|
try:
|
|
@@ -226,7 +263,11 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
|
|
|
226
263
|
stderr_text = stderr_bytes.decode("utf-8", errors="replace").strip()
|
|
227
264
|
|
|
228
265
|
# A: Extract artifacts from raw stdout (before clean_output!)
|
|
229
|
-
artifacts =
|
|
266
|
+
artifacts = [
|
|
267
|
+
(n, p)
|
|
268
|
+
for n, p in extract_artifacts(stdout_text)
|
|
269
|
+
if not is_sensitive_artifact_path(p, session_dir)
|
|
270
|
+
]
|
|
230
271
|
|
|
231
272
|
# B: (disabled) Scratch diff via before/after snapshots is removed because
|
|
232
273
|
# multi-user shared scratch causes cross-user artifact leakage.
|
|
@@ -237,6 +278,7 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
|
|
|
237
278
|
display = clean_output(stdout_text) or EMPTY_REPLY
|
|
238
279
|
|
|
239
280
|
# Strip file:/// links from display to avoid leaking server paths
|
|
281
|
+
display = _FILE_LINK_RE.sub(r"[\1]", display)
|
|
240
282
|
display = re.sub(
|
|
241
283
|
r"\[([^\]]+)\]\(file:///[^)]+\)",
|
|
242
284
|
r"[\1]",
|
|
@@ -293,8 +335,13 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
|
|
|
293
335
|
r_stderr_text = r_stderr.decode("utf-8", errors="replace").strip()
|
|
294
336
|
# Any useful stdout after retry counts as recovered (agy may exit non-zero)
|
|
295
337
|
if r_stdout_text:
|
|
296
|
-
r_artifacts =
|
|
338
|
+
r_artifacts = [
|
|
339
|
+
(n, p)
|
|
340
|
+
for n, p in extract_artifacts(r_stdout_text)
|
|
341
|
+
if not is_sensitive_artifact_path(p, session_dir)
|
|
342
|
+
]
|
|
297
343
|
r_display = clean_output(r_stdout_text) or EMPTY_REPLY
|
|
344
|
+
r_display = _FILE_LINK_RE.sub(r"[\1]", r_display)
|
|
298
345
|
r_display = re.sub(r"\[([^\]]+)\]\(file:///[^)]+\)", r"[\1]", r_display)
|
|
299
346
|
if (
|
|
300
347
|
first
|
|
@@ -310,9 +357,10 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
|
|
|
310
357
|
), []
|
|
311
358
|
return format_cli_error(r_stderr_text or stderr_text, backend="agy"), []
|
|
312
359
|
return format_cli_error(stderr_text, backend="agy"), []
|
|
313
|
-
# Non-zero exit: never treat raw stdout as a normal success reply
|
|
360
|
+
# Non-zero exit: never treat raw stdout as a normal success reply,
|
|
361
|
+
# but preserve any valid non-sensitive artifacts already emitted.
|
|
314
362
|
raw = stderr_text or stdout_text or display or "process exited abnormally"
|
|
315
|
-
return format_cli_error(raw, backend="agy"),
|
|
363
|
+
return format_cli_error(raw, backend="agy"), artifacts
|
|
316
364
|
|
|
317
365
|
# Success path only — never mark on ❌/🔔 error/throttle bubbles
|
|
318
366
|
if first and display != EMPTY_REPLY and not is_bridge_formatted_reply(display):
|
|
@@ -524,26 +572,44 @@ def handle_persona(args: str, user_id: str) -> str:
|
|
|
524
572
|
|
|
525
573
|
|
|
526
574
|
def _cmd_clear(user_id: str) -> str:
|
|
527
|
-
"""Handle /clear or /new: delete .initialized flag to start fresh."""
|
|
575
|
+
"""Handle /clear or /new: delete .initialized flag to start fresh and reset sticky plan mode."""
|
|
528
576
|
session_dir = get_session_dir(user_id)
|
|
529
577
|
clear_initialized(session_dir, backend="agy")
|
|
578
|
+
prefs_path = os.path.join(session_dir, "prefs.json")
|
|
579
|
+
if os.path.exists(prefs_path):
|
|
580
|
+
prefs = load_prefs(user_id)
|
|
581
|
+
if prefs.get("mode"):
|
|
582
|
+
update_active_prefs(user_id, mode="")
|
|
530
583
|
return "✅ **对话已重置** ✅"
|
|
531
584
|
|
|
532
585
|
|
|
533
|
-
|
|
534
|
-
|
|
586
|
+
_OFF_ARGS = frozenset({"off", "0", "false", "no", "normal", "default", "reset", "clear", "agent"})
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _cmd_fast(user_id: str, args: str = "") -> str:
|
|
590
|
+
"""Handle /fast [on|off]: set effort=low or toggle off (scoped to current backend)."""
|
|
591
|
+
sub = (args or "").strip().lower()
|
|
592
|
+
prefs = load_prefs(user_id)
|
|
593
|
+
if sub in _OFF_ARGS or (not sub and prefs.get("effort") == "low"):
|
|
594
|
+
update_active_prefs(user_id, effort="")
|
|
595
|
+
return "✅ **已关闭快速模式** ✅"
|
|
535
596
|
update_active_prefs(user_id, effort="low")
|
|
536
597
|
return "✅ **已开启快速模式** ✅"
|
|
537
598
|
|
|
538
599
|
|
|
539
|
-
def _cmd_planning(user_id: str) -> str:
|
|
540
|
-
"""Handle /planning: set mode=plan (scoped to current backend)."""
|
|
600
|
+
def _cmd_planning(user_id: str, args: str = "") -> str:
|
|
601
|
+
"""Handle /planning [on|off]: set mode=plan or toggle off (scoped to current backend)."""
|
|
602
|
+
sub = (args or "").strip().lower()
|
|
603
|
+
prefs = load_prefs(user_id)
|
|
604
|
+
if sub in _OFF_ARGS or (not sub and prefs.get("mode") == "plan"):
|
|
605
|
+
update_active_prefs(user_id, mode="")
|
|
606
|
+
return "✅ **已关闭规划模式** ✅"
|
|
541
607
|
update_active_prefs(user_id, mode="plan")
|
|
542
608
|
return "✅ **已开启规划模式** ✅"
|
|
543
609
|
|
|
544
610
|
|
|
545
611
|
def _cmd_add_dir(args: str, user_id: str) -> str:
|
|
546
|
-
"""Handle /add-dir <path>: add path to add_dirs list (dedup, validated)."""
|
|
612
|
+
"""Handle /add-dir <path>: add path to add_dirs list (dedup, validated, capped)."""
|
|
547
613
|
path = args.strip()
|
|
548
614
|
if not path:
|
|
549
615
|
return "❌ **缺少参数** ❌\n\n`/add-dir <路径>`"
|
|
@@ -554,12 +620,34 @@ def _cmd_add_dir(args: str, user_id: str) -> str:
|
|
|
554
620
|
prefs = load_prefs(user_id)
|
|
555
621
|
dirs = prefs.get("add_dirs", [])
|
|
556
622
|
if resolved not in dirs:
|
|
623
|
+
if len(dirs) >= MAX_ADD_DIRS:
|
|
624
|
+
return f"❌ **工作目录数量已达上限 ({MAX_ADD_DIRS})** ❌"
|
|
557
625
|
dirs.append(resolved)
|
|
558
626
|
prefs["add_dirs"] = dirs
|
|
559
627
|
save_prefs(user_id, prefs)
|
|
560
628
|
return f"✅ **已添加工作目录** ✅\n\n```\n{resolved}\n```"
|
|
561
629
|
|
|
562
630
|
|
|
631
|
+
def _parse_agy_model_ids(output: str) -> list[str]:
|
|
632
|
+
"""Extract canonical model IDs from ``agy models`` output.
|
|
633
|
+
|
|
634
|
+
``agy models`` outputs ``<model_id>\\t<Display Name>`` per line (e.g.
|
|
635
|
+
``gemini-4-argon-high\\tGemini 4 Argon (High)``). Strip the display column
|
|
636
|
+
and any status/banner lines so only valid model slugs are returned.
|
|
637
|
+
"""
|
|
638
|
+
models: list[str] = []
|
|
639
|
+
for raw_line in (output or "").split("\n"):
|
|
640
|
+
line = raw_line.strip()
|
|
641
|
+
if not line:
|
|
642
|
+
continue
|
|
643
|
+
if line.lower().startswith(("fetching available models", "available models", "error:")):
|
|
644
|
+
continue
|
|
645
|
+
model_id = line.split("\t", 1)[0].strip()
|
|
646
|
+
if model_id and " " not in model_id:
|
|
647
|
+
models.append(model_id)
|
|
648
|
+
return models
|
|
649
|
+
|
|
650
|
+
|
|
563
651
|
async def _cmd_model(args: str, user_id: str) -> str:
|
|
564
652
|
"""Handle /model <name>: validate against agy models list, then save.
|
|
565
653
|
|
|
@@ -576,7 +664,7 @@ async def _cmd_model(args: str, user_id: str) -> str:
|
|
|
576
664
|
if output.startswith("[error]") or is_bridge_formatted_reply(output):
|
|
577
665
|
return "❌ **无法获取模型列表** ❌"
|
|
578
666
|
|
|
579
|
-
models =
|
|
667
|
+
models = _parse_agy_model_ids(output)
|
|
580
668
|
|
|
581
669
|
# Exact match
|
|
582
670
|
if name in models:
|
|
@@ -652,10 +740,10 @@ async def handle_slash_command(text: str, user_id: str) -> str | None:
|
|
|
652
740
|
return _cmd_clear(user_id)
|
|
653
741
|
|
|
654
742
|
if cmd == "/fast":
|
|
655
|
-
return _cmd_fast(user_id)
|
|
743
|
+
return _cmd_fast(user_id, args)
|
|
656
744
|
|
|
657
745
|
if cmd == "/planning":
|
|
658
|
-
return _cmd_planning(user_id)
|
|
746
|
+
return _cmd_planning(user_id, args)
|
|
659
747
|
|
|
660
748
|
if cmd == "/model":
|
|
661
749
|
return await _cmd_model(args, user_id)
|
|
@@ -25,6 +25,7 @@ from .runner_common import (
|
|
|
25
25
|
clean_output, load_prefs, save_prefs, is_dangerous, parse_model_effort,
|
|
26
26
|
sanitize_env, terminate_process, update_active_prefs, is_bridge_formatted_reply,
|
|
27
27
|
format_error, format_cli_error, format_model_label, EMPTY_REPLY, validate_add_dir, path_is_under,
|
|
28
|
+
MAX_ADD_DIRS, is_sensitive_artifact_path, _sanitize_prompt_at_paths,
|
|
28
29
|
)
|
|
29
30
|
|
|
30
31
|
logger = logging.getLogger("codex_runner")
|
|
@@ -518,6 +519,8 @@ def _collect_codex_artifact(path: str, session_dir: str, since: float, seen: set
|
|
|
518
519
|
path = os.path.realpath(path)
|
|
519
520
|
except (OSError, ValueError):
|
|
520
521
|
return
|
|
522
|
+
if is_sensitive_artifact_path(path, session_dir):
|
|
523
|
+
return
|
|
521
524
|
try:
|
|
522
525
|
mtime = os.path.getmtime(path)
|
|
523
526
|
except OSError:
|
|
@@ -540,7 +543,7 @@ def _resolve_add_dirs(raw_dirs: list, session_dir: str, user_id: str = "") -> li
|
|
|
540
543
|
allowed roots used by the artifact fallback scan.
|
|
541
544
|
"""
|
|
542
545
|
validated = []
|
|
543
|
-
for d in raw_dirs or []:
|
|
546
|
+
for d in (raw_dirs or [])[:MAX_ADD_DIRS]:
|
|
544
547
|
if not d:
|
|
545
548
|
continue
|
|
546
549
|
try:
|
|
@@ -688,6 +691,11 @@ def _parse_codex_output(stdout_text: str, session_dir: str, since: float = 0.0)
|
|
|
688
691
|
last = EMPTY_REPLY
|
|
689
692
|
|
|
690
693
|
# 去掉文件里的 file:/// 链接(避免把内部路径直出给微信用户)
|
|
694
|
+
last = re.sub(
|
|
695
|
+
r"\[((?:[^\[\]]|\[[^\[\]]*\])+)\]\(file:///(?:[^()\s]|\([^()\s]*\))+\)",
|
|
696
|
+
r"[\1]",
|
|
697
|
+
last,
|
|
698
|
+
)
|
|
691
699
|
last = re.sub(
|
|
692
700
|
r"\[([^\]]+)\]\(file:///[^)]+\)",
|
|
693
701
|
r"[\1]",
|
|
@@ -754,7 +762,7 @@ def _collect_fallback_artifacts(session_dir: str, snapshot_before: dict, t0: flo
|
|
|
754
762
|
elif fmtime <= prev:
|
|
755
763
|
continue
|
|
756
764
|
name = os.path.basename(fp)
|
|
757
|
-
if name in _internal_names or name.startswith(".initialized."):
|
|
765
|
+
if name in _internal_names or name.startswith(".initialized.") or is_sensitive_artifact_path(fp, session_dir):
|
|
758
766
|
continue
|
|
759
767
|
key = (name, fp)
|
|
760
768
|
if key not in fallback_seen:
|
|
@@ -835,8 +843,9 @@ async def run_codex(prompt: str, user_id: str, timeout: int = None) -> tuple:
|
|
|
835
843
|
# 验证并规范化 add_dirs,确保命令构建与 fallback 扫描使用同一批目录
|
|
836
844
|
validated_add_dirs = _resolve_add_dirs(prefs.get("add_dirs"), session_dir, user_id)
|
|
837
845
|
prefs["add_dirs"] = validated_add_dirs
|
|
846
|
+
safe_prompt = _sanitize_prompt_at_paths(prompt, session_dir, extra_roots=validated_add_dirs)
|
|
838
847
|
# persona 经 AGENTS.md 由 codex 自动读取,这里无需注入 argv
|
|
839
|
-
cmd = _build_codex_command(
|
|
848
|
+
cmd = _build_codex_command(safe_prompt, prefs, first, thread_id)
|
|
840
849
|
|
|
841
850
|
# 执行前快照现有文件,用于 shell 产物 fallback 扫描(仅 session_dir)
|
|
842
851
|
snapshot_before = _snapshot_regular_files([session_dir])
|
|
@@ -922,7 +931,7 @@ async def run_codex(prompt: str, user_id: str, timeout: int = None) -> tuple:
|
|
|
922
931
|
)
|
|
923
932
|
clear_initialized(session_dir, backend="codex")
|
|
924
933
|
_delete_codex_thread_id(session_dir)
|
|
925
|
-
retry_cmd = _build_codex_command(
|
|
934
|
+
retry_cmd = _build_codex_command(safe_prompt, prefs, True, "")
|
|
926
935
|
retry_process = await asyncio.create_subprocess_exec(
|
|
927
936
|
*retry_cmd,
|
|
928
937
|
stdout=asyncio.subprocess.PIPE,
|
|
@@ -1349,13 +1358,26 @@ async def handle_codex_slash_command(text: str, user_id: str) -> str | None:
|
|
|
1349
1358
|
session_dir = get_session_dir(user_id)
|
|
1350
1359
|
clear_initialized(session_dir, backend="codex")
|
|
1351
1360
|
_delete_codex_thread_id(session_dir)
|
|
1361
|
+
prefs = load_prefs(user_id)
|
|
1362
|
+
if prefs.get("mode"):
|
|
1363
|
+
update_active_prefs(user_id, mode="")
|
|
1352
1364
|
return "✅ **对话已重置** ✅"
|
|
1353
1365
|
|
|
1354
1366
|
if cmd == "/fast":
|
|
1367
|
+
arg = args.strip().lower()
|
|
1368
|
+
prefs = load_prefs(user_id)
|
|
1369
|
+
if arg in ("off", "0", "false", "no", "normal", "default", "reset", "clear") or (not arg and prefs.get("effort") == "low"):
|
|
1370
|
+
update_active_prefs(user_id, effort="")
|
|
1371
|
+
return "✅ **已关闭快速模式** ✅(恢复默认思考强度)"
|
|
1355
1372
|
update_active_prefs(user_id, effort="low")
|
|
1356
1373
|
return "✅ **已开启快速模式** ✅"
|
|
1357
1374
|
|
|
1358
1375
|
if cmd == "/planning":
|
|
1376
|
+
arg = args.strip().lower()
|
|
1377
|
+
prefs = load_prefs(user_id)
|
|
1378
|
+
if arg in ("off", "0", "false", "no", "normal", "default", "reset", "clear", "agent") or (not arg and prefs.get("mode") == "plan"):
|
|
1379
|
+
update_active_prefs(user_id, mode="")
|
|
1380
|
+
return "✅ **已退出规划模式** ✅"
|
|
1359
1381
|
update_active_prefs(user_id, mode="plan")
|
|
1360
1382
|
return "✅ **已开启规划模式** ✅(只读沙箱,近似 plan mode)"
|
|
1361
1383
|
|
|
@@ -1376,6 +1398,8 @@ async def handle_codex_slash_command(text: str, user_id: str) -> str | None:
|
|
|
1376
1398
|
prefs = load_prefs(user_id)
|
|
1377
1399
|
dirs = prefs.get("add_dirs", [])
|
|
1378
1400
|
if resolved not in dirs:
|
|
1401
|
+
if len(dirs) >= MAX_ADD_DIRS:
|
|
1402
|
+
return f"❌ **附加目录数量已达上限 ({MAX_ADD_DIRS})** ❌"
|
|
1379
1403
|
dirs.append(resolved)
|
|
1380
1404
|
prefs["add_dirs"] = dirs
|
|
1381
1405
|
save_prefs(user_id, prefs)
|