Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,22 @@ jobs:
with:
python-version: "3.12"
cache: pip
cache-dependency-path: requirements-dev.txt
- name: Install dependencies
run: python -m pip install -r requirements-dev.txt
- name: Run tests
run: python -m pytest -q tests/
- name: Compile check
cache-dependency-path: requirements-lock.txt
- name: Install locked dependencies
run: python -m pip install --require-hashes -r requirements-lock.txt
- name: Lock drift check
# 语义:只在 requirements-dev.txt 声明的包名集合变化时报错。
run: python tools/check_lock_covers_dev.py
- name: Python tests + coverage gate
run: python -m pytest -q tests/ --cov --cov-fail-under=90
- name: Node tests (config_ui.html LAYER 1/2)
uses: actions/setup-node@v4
with:
node-version: "20"
- run: node --test tests/js/*.test.mjs
- name: Ruff
run: ruff check .
- name: Compile + shell syntax
run: |
python -m py_compile app.py util.py capture/mitmdump_entry.py tunnel/proxy.py services/config_server.py services/suanpan_runtime.py capture/capture.py capture/ai_capture_addon.py sysctl/system_proxy.py shellui/webview_window.py suanpan/main.py suanpan/router.py
bash -n build.sh scripts/build_dmg.sh scripts/notarize.sh
Expand Down
5 changes: 3 additions & 2 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@
from sysctl import login_item
from mpconf import netloc
from sysctl import port_check
from mpconf import config_store
from shellui.bridge_protocol import ACTION_OPEN_PATH, ACTION_RECONNECT_PROXY
from capture.capture import DEFAULT_CAPTURE_DIR, DEFAULT_CAPTURE_PORT
from mpconf.config import IdentityMigrationError, load_config, save_config, merge_config, DEFAULT_CONFIG
from mpconf.config import ( # noqa: F401 — DEFAULT_CONFIG 是模块导出符号
DEFAULT_CONFIG, IdentityMigrationError, load_config, merge_config,
save_config)
from shellui.log_window import LogBuffer, show_log_window
from shellui.webview_window import show_config_window
from shellui.menu_builder import MenuBuilder, MenuState, _status_color_for_connection
Expand Down
63 changes: 40 additions & 23 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -83,18 +83,16 @@ build_mitmdump
# (matches app.py's _resolve_mitmdump_bin() frozen-mode lookup).
echo "--- Building Magic AI Router.app ---"

# Main app venv: install ALL deps (rumps, pyobjc, Pillow, AND the Suanpan
# gateway deps — FastAPI, uvicorn, httpx, tiktoken, pydantic, etc.) so
# PyInstaller can discover and bundle every import. requirements-dev.txt
# supersedes requirements-lock.txt for the main build because suanpan deps use
# >= constraints incompatible with --require-hashes.
# Main app venv: 从带 hashes 的 requirements-lock.txt 安装(issue #14)——
# dev requirements 不决定发布成品;lock 已是主构建依赖的完整超集
# (rumps/pyobjc/Suanpan/mitmproxy/Pillow/PyInstaller 全部覆盖)。
MAIN_VENV=".build-venv-main"
rm -rf "$MAIN_VENV"
"$MAIN_PYTHON_BIN" -m venv "$MAIN_VENV"
# shellcheck source=/dev/null
source "$MAIN_VENV/bin/activate"
pip install -q --upgrade pip
pip install -q -r requirements-dev.txt
pip install -q --require-hashes -r requirements-lock.txt

# Generate the menu-bar state icon if missing (Pillow is in the venv). The
# production app icon is the approved v2 artwork under assets/icon; the menu-bar
Expand All @@ -118,33 +116,52 @@ python -m PyInstaller \
--windowed \
--name "Magic AI Router" \
--add-data "build_time.txt:." \
--add-data "services/stats.py:." \
--add-data "tunnel/proxy.py:." \
--add-data "services/config_server.py:." \
--add-data "shellui/config_ui.html:." \
--add-data "docs/agent.md:." \
--add-data "shellui/webview_window.py:." \
--add-data "sysctl/keychain.py:." \
--add-data "docs/examples/suanpan.example.yaml:." \
--add-data "capture/ai_capture_addon.py:." \
--add-data "assets/MenubarIcon.png:." \
--add-data "assets/MenubarIcon-gray.png:." \
--add-data "assets/MenubarIcon-yellow.png:." \
--add-data "util.py:." \
--add-data "services/stats.py:." \
--add-data "tunnel/proxy.py:." \
--add-data "tunnel/async_runtime.py:." \
--add-data "tunnel/http_framer.py:." \
--add-data "tunnel/connection_coordinator.py:." \
--add-data "tunnel/subprocess_monitor.py:." \
--add-data "tunnel/retry_scheduler.py:." \
--add-data "tunnel/host_key.py:." \
--add-data "sysctl/system_proxy.py:." \
--add-data "sysctl/port_check.py:." \
--add-data "tunnel/host_key_flow.py:." \
--add-data "mpconf/config.py:." \
--add-data "mpconf/config_store.py:." \
--add-data "mpconf/config_state.py:." \
--add-data "mpconf/netloc.py:." \
--add-data "mpconf/provider_auth.py:." \
--add-data "shellui/menu_builder.py:." \
--add-data "shellui/webview_window.py:." \
--add-data "shellui/log_window.py:." \
--add-data "shellui/bridge_protocol.py:." \
--add-data "capture/capture.py:." \
--add-data "capture/capture_controller.py:." \
--add-data "capture/capture_store.py:." \
--add-data "mpconf/config.py:." \
--add-data "tunnel/subprocess_monitor.py:." \
--add-data "sysctl/sys_proxy_controller.py:." \
--add-data "tunnel/retry_scheduler.py:." \
--add-data "shellui/menu_builder.py:." \
--add-data "tunnel/host_key_flow.py:." \
--add-data "capture/ca_trust.py:." \
--add-data "capture/ai_capture_addon.py:." \
--add-data "capture/chromium_proxy.py:." \
--add-data "capture/resources.py:." \
--add-data "capture/mitmdump_entry.py:." \
--add-data "sysctl/system_proxy.py:." \
--add-data "sysctl/sys_proxy_controller.py:." \
--add-data "sysctl/sleep_blocker.py:." \
--add-data "sysctl/login_item.py:." \
--add-data "sysctl/port_check.py:." \
--add-data "sysctl/keychain.py:." \
--add-data "sysctl/instance_owner.py:." \
--add-data "services/config_server.py:." \
--add-data "services/suanpan_runtime.py:." \
--add-data "assets/MenubarIcon.png:." \
--add-data "assets/MenubarIcon-gray.png:." \
--add-data "assets/MenubarIcon-yellow.png:." \
--add-data "services/claude_code_setup.py:." \
--add-data "services/lifecycle_runtime.py:." \
--add-data "services/balance_usage.py:." \
--add-data "services/authenticated_http.py:." \
--add-data "dist-mitmdump/mitmdump:mitmdump" \
--collect-all suanpan \
--collect-submodules uvicorn \
Expand Down
2 changes: 1 addition & 1 deletion capture/capture_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from capture import ca_trust
from mpconf import netloc
from capture.capture_store import DEFAULT_CAPTURE_DIR, DEFAULT_CAPTURE_PORT
from capture.capture_store import DEFAULT_CAPTURE_PORT
from util import truncate as _truncate
from capture.resources import CaptureResourcesError, resolve_capture_resources

Expand Down
2 changes: 1 addition & 1 deletion mpconf/config_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ def prepare(self, mp=None, sp=None) -> CommitPlan:
lp = sp_c.get("listen_port")
if lp is not None and (not isinstance(lp, int)
or not 1 <= lp <= _SP_PORT_MAX):
errors.append(f"listen_port 端口无效(须 1..65535)")
errors.append("listen_port 端口无效(须 1..65535)")
server = sp_c.get("server") or {}
timeout = server.get("request_timeout_s")
if timeout is not None and (not isinstance(timeout, int)
Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[tool.ruff]
# 测试文件按段导入(每段就近引入该段所需模块)是库内成熟惯例——
# 逐行 noqa 会让 30+ 处 E402 噪音盖过真实问题。
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["E402"]
3 changes: 2 additions & 1 deletion services/balance_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@
AuthenticatedHttpClient,
)

_BALANCE_CLIENT = AuthenticatedHttpClient(timeout=10)
from datetime import datetime, timedelta, timezone

from mpconf.provider_auth import build_outbound_headers, resolve_api_key

_BALANCE_CLIENT = AuthenticatedHttpClient(timeout=10)

logger = logging.getLogger("magic-proxy.balance_usage")

# Provider → balance-API table: (host fragment, [(url, auth-style, label), ...]).
Expand Down
3 changes: 1 addition & 2 deletions services/config_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from tunnel import host_key
from services import claude_code_setup
from capture import capture_store
from mpconf.config import load_config, save_config, merge_config
from mpconf.config import load_config, merge_config
from services.balance_usage import (
USAGE_RANGES,
fetch_balance,
Expand All @@ -47,7 +47,6 @@ def _read_mp():
except Exception:
# 迁移可行动错误等:降级为带 _load_error 的空态供 UI 提示,
# /api/state 不 500(UI 保存被 validateConfig/prepare 双带阻断)
from mpconf.config import IdentityMigrationError
import logging as _lg
_lg.getLogger("magic-proxy.config_server").exception("_read_mp degraded")
return {"_load_error": "Magic Proxy 配置装载失败,已阻止保存以防覆盖"}
Expand Down
1 change: 0 additions & 1 deletion services/lifecycle_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@

import logging
import os
import sys

from sysctl import port_check, sleep_blocker
from sysctl.instance_owner import InstanceOwner
Expand Down
3 changes: 1 addition & 2 deletions shellui/menu_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,11 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import Any, Callable
from typing import Callable
import logging

import rumps
from capture import chromium_proxy
from mpconf.config import save_config
from util import resource_path as _resource_path, truncate as _truncate

logger = logging.getLogger("magic-proxy.menu")
Expand Down
1 change: 0 additions & 1 deletion suanpan/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
import yaml
from pydantic import BaseModel, ConfigDict, Field, model_validator

from mpconf.provider_auth import HOP_HEADERS as _HOP_HEADERS
from mpconf.provider_auth import build_outbound_headers as _build_outbound
from mpconf.provider_auth import resolve_api_key as _resolve_key

Expand Down
1 change: 0 additions & 1 deletion tests/test_async_runtime_races.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,6 @@ async def never():
class TestGenerationIsolation(unittest.TestCase):
def test_old_generation_cleanup_does_not_clobber_new(self):
rt = AsyncRuntime("t")
errors = []
def factory(loop):
async def noop():
pass
Expand Down
1 change: 0 additions & 1 deletion tests/test_authenticated_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
**凭证绝不出原始 origin**。
"""
import http.server
import json
import threading
import unittest

Expand Down
2 changes: 1 addition & 1 deletion tests/test_balance_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ def side_effect(url, headers=None, data=None, method=None, timeout=None):
self.assertEqual(r, {"models": ["m1"]})
self.assertEqual(m.call_args_list[-1][0][0], "https://api.deepseek.com/anthropic/models")

def test_404_falls_back_to_models_without_v1(self):
def test_404_falls_back_to_models_without_v1_second(self):
def side_effect(url, headers=None, data=None, method=None, timeout=None):
if url.endswith("/v1/models"):
raise _http_error(404)
Expand Down
1 change: 0 additions & 1 deletion tests/test_ca_trust.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
and would persist a change to the real trust store. Every test here mocks
subprocess.run/Popen.
"""
import os
import subprocess
import unittest
from unittest.mock import patch, MagicMock
Expand Down
12 changes: 6 additions & 6 deletions tests/test_capture_accumulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ class TestStoreConvergence(unittest.TestCase):
"""issue #11:单条超大拒收 + append 后总量收敛。"""

def test_oversized_record_rejected(self):
import json as _json
from capture import capture_store as cs
import tempfile, pathlib, os
import tempfile
import os
with tempfile.TemporaryDirectory(dir=os.path.expanduser("~")) as d:
cs.append_json({"seed": 1}, os.path.join(d, "cap")) # 首条建目录+marker
d = os.path.join(d, "cap")
Expand All @@ -66,22 +66,22 @@ def test_oversized_record_rejected(self):

def test_post_append_trims_to_store_budget(self):
from capture import capture_store as cs
import tempfile, os
import tempfile
import os
with tempfile.TemporaryDirectory(dir=os.path.expanduser("~")) as d:
cs.append_json({"seed": 1}, os.path.join(d, "cap")) # 建目录
d = os.path.join(d, "cap")
small = cs.MAX_STORE_BYTES
old_total = cs.MAX_STORE_BYTES
try:
# 两文件各 ~3/4 上限 → append 第二条后总量超限触发收敛
cs.MAX_STORE_BYTES = 100
cs.MAX_FILE_BYTES = 1000
cs.append_json({"d": "y" * 60}, d)
# 人造第二个更旧文件使总量超限
import time
p = pathlib.Path(d) / "2026-01-01.jsonl"
p.write_text("z" * 80)
st = os.stat(p); os.utime(p, (st.st_atime - 100, st.st_mtime - 100))
st = os.stat(p)
os.utime(p, (st.st_atime - 100, st.st_mtime - 100))
cs.append_json({"d": "y" * 5}, d)
total = sum(f.stat().st_size for f in pathlib.Path(d).glob("*.jsonl*"))
self.assertLessEqual(total, cs.MAX_STORE_BYTES + 20,
Expand Down
12 changes: 8 additions & 4 deletions tests/test_capture_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,15 +136,18 @@ def test_enable_returns_false_when_start_fails(self):

class TestMenuTitle(unittest.TestCase):
def test_running_shows_on(self):
ctrl = _ctrl(status="running"); ctrl._enabled = True
ctrl = _ctrl(status="running")
ctrl._enabled = True
self.assertEqual(ctrl.menu_title(), "抓包模式:开")

def test_starting_shows_transitional(self):
ctrl = _ctrl(status="starting"); ctrl._enabled = True
ctrl = _ctrl(status="starting")
ctrl._enabled = True
self.assertEqual(ctrl.menu_title(), "抓包模式:启动中…")

def test_error_shows_warning_even_if_enabled(self):
ctrl = _ctrl(status="error"); ctrl._enabled = True
ctrl = _ctrl(status="error")
ctrl._enabled = True
self.assertEqual(ctrl.menu_title(), "抓包模式:异常")

def test_off_and_ca_trusted_shows_plain_off(self):
Expand All @@ -156,7 +159,8 @@ def test_off_and_ca_not_trusted_shows_hint(self):
self.assertEqual(_ctrl(status="stopped").menu_title(), "抓包模式:关(需信任证书)")

def test_does_not_check_ca_trust_while_enabled(self):
ctrl = _ctrl(status="running"); ctrl._enabled = True
ctrl = _ctrl(status="running")
ctrl._enabled = True
with patch("capture.ca_trust.is_trusted") as is_trusted:
ctrl.menu_title()
is_trusted.assert_not_called()
Expand Down
1 change: 0 additions & 1 deletion tests/test_capture_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
from capture.resources import CaptureResources


from sysctl import sleep_blocker
from sysctl import system_proxy


Expand Down
2 changes: 1 addition & 1 deletion tests/test_claude_code_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,7 @@ def test_explicit_roles_override_sp_derivation(self):
with patch("services.claude_code_setup.config_store.sp_load_raw",
return_value=sp), \
patch.dict(config_store.PATHS, {"claude_settings": settings_path}):
result = claude_code_setup.setup(roles=roles)
claude_code_setup.setup(roles=roles)
with open(settings_path) as f:
written = json.load(f)
env = written["env"]
Expand Down
2 changes: 0 additions & 2 deletions tests/test_config_server_extra.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
"""Tests for config_server.py — sp save pipeline, handler dispatch."""
import json
import os
import unittest
from unittest.mock import patch, MagicMock

from services import config_server
from mpconf import config_store
Expand Down
10 changes: 4 additions & 6 deletions tests/test_config_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@
from unittest import mock # noqa: F401
from pathlib import Path

from mpconf import config_state
from mpconf.config_state import ConfigStateStore, LoadResult
from mpconf.config_state import ConfigStateStore


class TestLoadStates(unittest.TestCase):
Expand Down Expand Up @@ -291,7 +290,6 @@ def _store(self):
sp_path=str(Path(d.name) / "suanpan.yaml"))

def test_invalid_main_file_does_not_clobber_good_backup(self):
import stat
store = self._store()
# 先正常提交一轮(建立良好状态),此时 .bak 尚不存在
plan = store.prepare(sp={"listen_port": 9527})
Expand Down Expand Up @@ -577,7 +575,7 @@ class TestProviderIdSemantics(unittest.TestCase):
"""issue #8 S3:rename 后 keep/replace/clear 三态按 id 正确。"""

def _roundtrip(self, old_providers, new_providers):
import tempfile, os
import tempfile
from pathlib import Path
from suanpan.config import save_config_dict, load_config_raw
with tempfile.TemporaryDirectory() as d:
Expand Down Expand Up @@ -691,7 +689,7 @@ class TestRound3Holes(unittest.TestCase):

def test_explicit_id_tunnel_registers_identity(self):
"""已迁移 A(id=hash 身份)+ 手工加同身份无 id B → B 不再静默同 id。"""
from mpconf.config import assign_stable_ids, stable_tunnel_id, IdentityMigrationError
from mpconf.config import assign_stable_ids, stable_tunnel_id
real_id = stable_tunnel_id("u", "h", 22)
tunnels = [
{"id": real_id, "name": "a", "ssh_user": "u",
Expand All @@ -704,7 +702,7 @@ def test_explicit_id_tunnel_registers_identity(self):
"B 得序数后缀 id,绝不与 A 静默同 id")

def test_fresh_assign_collision_detected(self):
from mpconf.config import assign_stable_ids, IdentityMigrationError
from mpconf.config import assign_stable_ids
# 两条同身份隧道:第一条得 ordinal=1 id;第二条 #2 后缀
# 但若 #2 id 撞显式 id —— 分配后查重兜底
tunnels = [
Expand Down
Loading
Loading