wechatbridge-cli 1.6.2__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.6.2 → wechatbridge_cli-1.7.1}/PKG-INFO +1 -1
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/tests/test_dsh.py +2 -2
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/tests/test_hardening.py +256 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge/__init__.py +1 -1
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge/agy.py +115 -27
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge/codex.py +28 -4
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge/dsh.py +13 -70
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge/grok.py +28 -3
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge/ilink.py +40 -6
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge/main.py +135 -17
- wechatbridge_cli-1.7.1/wechatbridge/price.py +444 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge/runner_common.py +264 -2
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge_cli.egg-info/PKG-INFO +1 -1
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge_cli.egg-info/SOURCES.txt +1 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/LICENSE +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/README.md +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/pyproject.toml +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/setup.cfg +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/tests/test_codex.py +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/tests/test_codex_auth.py +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/tests/test_grok_auth.py +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge/__main__.py +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge/config.py +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge/update_check.py +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge_cli.egg-info/requires.txt +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.1}/wechatbridge_cli.egg-info/top_level.txt +0 -0
|
@@ -514,7 +514,7 @@ class TestRunDshIntegration(unittest.IsolatedAsyncioTestCase, _DshIntegrationBas
|
|
|
514
514
|
|
|
515
515
|
async def test_empty_reply(self):
|
|
516
516
|
display, artifacts = await self._run("hi", mode="empty")
|
|
517
|
-
self.assertEqual(display, "
|
|
517
|
+
self.assertEqual(display, "🔔 对话额度已用尽\n\n后端没有返回任何内容,通常是本账号的对话额度/用量已耗尽。请稍后再试或联系管理员确认额度。")
|
|
518
518
|
self.assertEqual(artifacts, [])
|
|
519
519
|
|
|
520
520
|
async def test_nonzero_exit_maps_to_error_bubble(self):
|
|
@@ -1210,7 +1210,7 @@ class TestRunDshMemoryIntegration(unittest.IsolatedAsyncioTestCase, _DshIntegrat
|
|
|
1210
1210
|
display, artifacts = await self._run("继续", user_id=user_id, mode="empty")
|
|
1211
1211
|
self.assertIn("上下文安全提示", display)
|
|
1212
1212
|
self.assertIn("历史对话记录触发安全策略", display)
|
|
1213
|
-
self.assertIn("
|
|
1213
|
+
self.assertIn("对话额度已用尽", display)
|
|
1214
1214
|
|
|
1215
1215
|
async def test_run_dsh_shows_notice_on_error_reply_when_context_dropped(self):
|
|
1216
1216
|
"""When context was dropped and command exits nonzero / fails, safety notice is preserved separately from error bubble."""
|
|
@@ -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.
|
|
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)
|