From 729b3a80c7efd2641deca174918c1d2518f09b91 Mon Sep 17 00:00:00 2001 From: benz-ai-x <317748583+benz-ai-x@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:58:01 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(identity):=20Provider/Tunnel=20?= =?UTF-8?q?=E7=A8=B3=E5=AE=9A=E6=8C=81=E4=B9=85=20id=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=87=AD=E8=AF=81=E4=B8=8E=E5=8F=AF=E7=BC=96=E8=BE=91=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E8=A7=A3=E8=80=A6=EF=BC=88#8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tunnel:装载期确定性赋 id(t-,assign_stable_ids); 重复身份/重复 id 可行动报错;重命名/改地址不改 id - Keychain:账户优先 tunnel:;legacy user@host:port 作迁移期回退读; set 只写 id 账户;delete 双账户清理(新+legacy) - Provider:装载期确定性赋 id(p-,幂等);_restore_key 按 id 匹配——重命名保住 key;id 不同绝不按名串接(仅 legacy 无 id 档按名 回退) - prepare re-pin:password 隧道无新密时经 legacy 回退读旧密码迁到 id 账户,旧条目随事务双账户清理(复用 #6 的 keychain_sets/dels 计划) - JS:providers/tunnels snapshot 直通 id(id 变更=dirty;collect 原地 改不改 id) - 测试:确定性/幂等迁移、重复报错、rename 保 key、id 不同不串接、 clear 语义、re-pin、装载即赋 - ADR-002 增补稳定 id 与凭证所有权 pytest 1418 + node 94 全绿。 Co-Authored-By: Claude Fable 5 --- .../002-config-representation-and-masking.md | 6 + mpconf/config.py | 36 ++++ mpconf/config_state.py | 10 +- shellui/config_ui.html | 3 +- suanpan/config.py | 45 ++++- sysctl/keychain.py | 40 +++-- tests/test_config_state.py | 155 ++++++++++++++++++ tests/test_keychain_id.py | 62 +++++++ 8 files changed, 342 insertions(+), 15 deletions(-) create mode 100644 tests/test_keychain_id.py diff --git a/docs/adr/002-config-representation-and-masking.md b/docs/adr/002-config-representation-and-masking.md index 4a9bfb2..440af29 100644 --- a/docs/adr/002-config-representation-and-masking.md +++ b/docs/adr/002-config-representation-and-masking.md @@ -59,3 +59,9 @@ Suanpan provider 的 `api_key` 发给设置窗(WKWebView)时,不再发掩 - `on_sp_saved` 回调只在完整提交后触发;失败返回结构化阶段(validate/journal/mp/sp/keychain)且错误不含 secret。 - 跨文件提交崩溃由启动时 `recover()` 幂等重放 journal 补齐。 - invalid 主文件不覆盖最后已知良好的 `.bak`;首创建与保存共用 0600/0700 权限路径。 + +## 增补(2026-08-20,issue #8):稳定 id 与凭证所有权 + +- Tunnel 持不可变 `id`(`t-`,装载期确定性迁移赋值;重复身份/重复 id 抛可行动错误,不猜 secret 归属);Keychain 账户优先 `tunnel:`,无 id 时 legacy `user@host:port` 仅作迁移期回退读。重命名/改地址不改 id 不丢密码。 +- Provider 持 `id`(`p-`);api_key 的 keep/replace/clear(`_restore_key`)按 id 匹配旧值——重命名保住 key,id 不同绝不按名串接。legacy 无 id 旧档按名回退。 +- 旧 Keychain 条目迁移:password 隧道在下次提交时经 ConfigStateStore.prepare 的 re-pin(legacy 回退读取 → 写 id 账户 → 双账户清理)随事务完成。 diff --git a/mpconf/config.py b/mpconf/config.py index 8641a8e..0ef0f1d 100644 --- a/mpconf/config.py +++ b/mpconf/config.py @@ -46,6 +46,41 @@ } +def stable_tunnel_id(user: str, host: str, port) -> str: + """确定性 id:t-——同身份恒同 id(issue #8)。""" + import hashlib + basis = f"{user or ''}@{host or ''}:{port or 22}" + return "t-" + hashlib.sha1(basis.encode("utf-8")).hexdigest()[:10] + + +def assign_stable_ids(tunnels) -> int: + """为无 id 的隧道赋确定性 id;重复身份/重复 id 抛可行动错误。 + + 返回迁移数量。已有 id 一律不动(重命名/改地址不影响)。 + """ + seen_ids, seen_identity = {}, {} + migrated = 0 + for t in tunnels or []: + ident = f"{t.get('ssh_user', '')}@{t.get('ssh_host', '')}:{t.get('ssh_port', 22)}" + if t.get("id"): + if t["id"] in seen_ids: + raise ValueError( + f"隧道配置存在重复 id:{t['id']}(请修正配置文件后重试)") + seen_ids[t["id"]] = ident + continue + if ident in seen_identity: + raise ValueError( + f"隧道重复身份 {ident}:无法确定旧密码归属," + "请先在配置文件中区分这两条隧道(改 user/host/port)") + seen_identity[ident] = True + t["id"] = stable_tunnel_id(t.get("ssh_user", ""), + t.get("ssh_host", ""), + t.get("ssh_port", 22)) + seen_ids[t["id"]] = ident + migrated += 1 + return migrated + + def load_config(path=None): """Load and migrate config; returns merged dict or None.""" p = path or get_path("mp") @@ -56,6 +91,7 @@ def load_config(path=None): cfg = json.load(f) before = json.dumps(cfg, sort_keys=True) migrated = _migrate(cfg) + assign_stable_ids(migrated.get("tunnels") or []) # issue #8 if json.dumps(migrated, sort_keys=True) != before and not save_config(migrated, p): # The migrated dict is already clean in memory, but the file on # disk is still the pre-migration version — it may hold plaintext diff --git a/mpconf/config_state.py b/mpconf/config_state.py index 8754275..067b9f5 100644 --- a/mpconf/config_state.py +++ b/mpconf/config_state.py @@ -151,9 +151,17 @@ def prepare(self, mp=None, sp=None) -> CommitPlan: t.pop("has_password", None) # 服务端注入的只读字段 t.pop("capture_active", None) pw = t.pop("password", None) - # 显式切换离 password 才删(部分载荷不得静默清密) if pw: kc_sets.append((dict(t), pw)) + elif t.get("auth_type") == "password" and t.get("id"): + # issue #8 re-pin:id 稳定后把 legacy 账户里的旧密码 + # 迁到 id 账户(get 的 legacy 回退读到);旧条目由 + # delete_password 的双账户清理随本次提交移除 + legacy = {k: v for k, v in t.items() if k != "id"} + old_pw = (self._keychain.get_password(legacy) + if self._keychain else "") + if old_pw: + kc_sets.append((dict(t), old_pw)) elif "auth_type" in t and t.get("auth_type") != "password": kc_dels.append(dict(t)) return CommitPlan(True, [], mp_c, sp_c, kc_sets, kc_dels) diff --git a/shellui/config_ui.html b/shellui/config_ui.html index 4fbe838..252cd44 100644 --- a/shellui/config_ui.html +++ b/shellui/config_ui.html @@ -360,7 +360,7 @@ if(view==='tunnel')return{ current_tunnel:numberDefault(mp.current_tunnel,0), tunnels:(mp.tunnels||[]).map(t=>({ - name:t.name||'',ssh_user:t.ssh_user||'',ssh_host:t.ssh_host||'', + id:t.id||'',name:t.name||'',ssh_user:t.ssh_user||'',ssh_host:t.ssh_host||'', ssh_port:numberDefault(t.ssh_port,22),auth_type:t.auth_type||'key', ssh_key:t.ssh_key||'',ssh_compression:t.ssh_compression!==false, password:t.password||(t.has_password?'__saved_password__':''), @@ -385,6 +385,7 @@ Object.keys(sp.providers||{}).sort().forEach(name=>{ const p=sp.providers[name]||{}; providers[name]={ + id:p.id||'', base_url:p.base_url||'', api_key:p.api_key||(p.api_key_set?'__saved_api_key__':''), api_key_env:p.api_key_env||'',auth_header:p.auth_header||'', diff --git a/suanpan/config.py b/suanpan/config.py index 30592f1..1e4d0ed 100644 --- a/suanpan/config.py +++ b/suanpan/config.py @@ -20,6 +20,9 @@ class ProviderConfig(BaseModel): model_config = ConfigDict(validate_assignment=True) + # 稳定身份(issue #8):不可变、无业务含义;显示名可随意改, + # api_key 的 keep/replace/clear 恢复按 id 匹配旧值。 + id: str | None = None base_url: str api_key: str | None = None api_key_env: str | None = None @@ -155,6 +158,29 @@ def _restore_key(new_val, old_val, keep): return new_val or None +def assign_provider_ids(cfg: dict) -> int: + """为无 id 的 provider 赋确定性 id(p-)。 + + 重复 id 抛可行动错误——不猜测 secret 归属(issue #8)。 + """ + import hashlib + seen, migrated = set(), 0 + for name, p in (cfg.get("providers") or {}).items(): + if not isinstance(p, dict): + continue + pid = p.get("id") + if pid: + if pid in seen: + raise ValueError( + f"供应商存在重复 id:{pid}(请修正配置文件后重试)") + seen.add(pid) + continue + p["id"] = "p-" + hashlib.sha1(name.encode("utf-8")).hexdigest()[:10] + seen.add(p["id"]) + migrated += 1 + return migrated + + def load_config_raw(path: Path | str) -> dict: """Read raw (unmasked) config dict from YAML. Returns {} on any error.""" p = Path(path) @@ -164,6 +190,11 @@ def load_config_raw(path: Path | str) -> dict: data = yaml.safe_load(p.read_text()) except Exception: return {} + if isinstance(data, dict): + try: + assign_provider_ids(data) # issue #8:装载即赋幂等 id + except ValueError: + raise # 重复 id 等可行动错误上抛,绝不猜测 secret 归属 return data if isinstance(data, dict) else {} @@ -190,11 +221,21 @@ def save_config_dict(data: dict, path: Path | str) -> tuple[bool, str | None]: Returns (ok, error_msg). """ old = load_config_raw(path) - old_providers = old.get("providers", {}) + old_by_id = {p.get("id"): p + for p in old.get("providers", {}).values() + if isinstance(p, dict) and p.get("id")} + old_by_name = old.get("providers", {}) for name, p in data.get("providers", {}).items(): + # id 命中 → 同一实体(重命名后 key 仍恢复)。id 未命中时仅当旧档 + # 无 id(legacy)才按名回退——有 id 而不同 ≠ 同一实体,绝不串接。 + old_p = old_by_id.get(p.get("id")) + if old_p is None: + legacy = old_by_name.get(name, {}) + if isinstance(legacy, dict) and not legacy.get("id"): + old_p = legacy p["api_key"] = _restore_key( p.get("api_key"), - old_providers.get(name, {}).get("api_key"), + (old_p or {}).get("api_key"), bool(p.pop("api_key_set", False))) data["api_key"] = _restore_key( data.get("api_key"), old.get("api_key"), diff --git a/sysctl/keychain.py b/sysctl/keychain.py index 0db3514..7643f00 100644 --- a/sysctl/keychain.py +++ b/sysctl/keychain.py @@ -14,17 +14,28 @@ def _account(tunnel: dict) -> str: + """凭证账户名:优先稳定 id(issue #8);无 id 时为 legacy 推导。""" + stable_id = tunnel.get("id") + if stable_id: + return f"tunnel:{stable_id}" user = tunnel.get("ssh_user", "") host = tunnel.get("ssh_host", "") port = tunnel.get("ssh_port", 22) return f"{user}@{host}:{port}" -def _base_query(tunnel: dict) -> dict: +def _legacy_account(tunnel: dict) -> str: + user = tunnel.get("ssh_user", "") + host = tunnel.get("ssh_host", "") + port = tunnel.get("ssh_port", 22) + return f"{user}@{host}:{port}" + + +def _base_query(tunnel: dict, account: str | None = None) -> dict: return { Security.kSecClass: Security.kSecClassGenericPassword, Security.kSecAttrService: SERVICE, - Security.kSecAttrAccount: _account(tunnel), + Security.kSecAttrAccount: account or _account(tunnel), } @@ -33,8 +44,9 @@ def set_password(tunnel: dict, password: str) -> bool: return False try: # Replace any existing entry so -U semantics (update-in-place) hold. - Security.SecItemDelete(_base_query(tunnel)) - attrs = dict(_base_query(tunnel)) + final_account = _account(tunnel) + Security.SecItemDelete(_base_query(tunnel, final_account)) + attrs = _base_query(tunnel, final_account) attrs[Security.kSecValueData] = password.encode("utf-8") status = Security.SecItemAdd(attrs, None) ok = status[0] == Security.errSecSuccess if isinstance(status, tuple) \ @@ -51,13 +63,18 @@ def set_password(tunnel: dict, password: str) -> bool: def get_password(tunnel: dict) -> str: if not tunnel.get("ssh_host"): return "" + accounts = [_account(tunnel)] + legacy = _legacy_account(tunnel) + if legacy not in accounts: + accounts.append(legacy) # 迁移期回退读(issue #8) try: - query = dict(_base_query(tunnel)) - query[Security.kSecReturnData] = True - query[Security.kSecMatchLimit] = Security.kSecMatchLimitOne - status, data = Security.SecItemCopyMatching(query, None) - if status == Security.errSecSuccess and data is not None: - return bytes(data).decode("utf-8") + for account in accounts: + query = _base_query(tunnel, account) + query[Security.kSecReturnData] = True + query[Security.kSecMatchLimit] = Security.kSecMatchLimitOne + status, data = Security.SecItemCopyMatching(query, None) + if status == Security.errSecSuccess and data is not None: + return bytes(data).decode("utf-8") except Exception as e: # noqa: BLE001 logger.warning("Keychain get failed: %s", type(e).__name__) return "" @@ -68,7 +85,8 @@ def delete_password(tunnel: dict) -> bool: if not tunnel.get("ssh_host"): return True try: - Security.SecItemDelete(_base_query(tunnel)) + for account in {_account(tunnel), _legacy_account(tunnel)}: + Security.SecItemDelete(_base_query(tunnel, account)) return True except Exception as e: # noqa: BLE001 logger.warning("Keychain delete failed: %s", type(e).__name__) diff --git a/tests/test_config_state.py b/tests/test_config_state.py index acc3646..414ea7d 100644 --- a/tests/test_config_state.py +++ b/tests/test_config_state.py @@ -427,3 +427,158 @@ def delete_password(self, t): self.assertFalse(result.ok) self.assertEqual(result.stage, "keychain") self.assertTrue(any("旧密码清理失败" in e for e in result.errors)) + + +class TestStableIdentityMigration(unittest.TestCase): + """issue #8 S2:load/prepare 期确定性 id 迁移;重命名不改 id。""" + + def test_legacy_tunnel_gets_deterministic_id(self): + from mpconf.config import assign_stable_ids + tunnels = [{"name": "a", "ssh_user": "u", "ssh_host": "h", + "ssh_port": 22}] + ids = assign_stable_ids(tunnels) + self.assertTrue(tunnels[0]["id"].startswith("t-")) + self.assertEqual(len(tunnels[0]["id"]), 12) + self.assertEqual(ids, 1, "恰一个迁移") + # 确定性:同身份重算同 id + again = [{"name": "a", "ssh_user": "u", "ssh_host": "h", + "ssh_port": 22}] + assign_stable_ids(again) + self.assertEqual(again[0]["id"], tunnels[0]["id"]) + + def test_existing_id_untouched_and_stable_across_edits(self): + from mpconf.config import assign_stable_ids + tunnels = [{"id": "t-keepme1234", "ssh_user": "u", + "ssh_host": "h", "ssh_port": 22}] + assign_stable_ids(tunnels) + self.assertEqual(tunnels[0]["id"], "t-keepme1234") + tunnels[0]["ssh_host"] = "changed.example.com" # 编辑地址 + tunnels[0]["name"] = "renamed" + assign_stable_ids(tunnels) + self.assertEqual(tunnels[0]["id"], "t-keepme1234", + "重命名/改地址不改变 id") + + def test_duplicate_legacy_identity_fails_actionable(self): + from mpconf.config import assign_stable_ids + dup = [{"name": "a", "ssh_user": "u", "ssh_host": "h", "ssh_port": 22}, + {"name": "b", "ssh_user": "u", "ssh_host": "h", "ssh_port": 22}] + with self.assertRaises(ValueError) as ctx: + assign_stable_ids(dup) + self.assertIn("重复身份", str(ctx.exception)) + self.assertIn("u@h:22", str(ctx.exception)) + + def test_duplicate_explicit_ids_fails_actionable(self): + from mpconf.config import assign_stable_ids + dup = [{"id": "t-same", "ssh_user": "u1", "ssh_host": "h", "ssh_port": 22}, + {"id": "t-same", "ssh_user": "u2", "ssh_host": "h", "ssh_port": 22}] + with self.assertRaises(ValueError) as ctx: + assign_stable_ids(dup) + self.assertIn("t-same", str(ctx.exception)) + + +class TestProviderIdSemantics(unittest.TestCase): + """issue #8 S3:rename 后 keep/replace/clear 三态按 id 正确。""" + + def _roundtrip(self, old_providers, new_providers): + import tempfile, os + from pathlib import Path + from suanpan.config import save_config_dict, load_config_raw + with tempfile.TemporaryDirectory() as d: + path = str(Path(d) / "s.yaml") + Path(path).write_text(yaml.dump( + {"providers": old_providers, "listen_port": 9527}, + allow_unicode=True)) + ok, err = save_config_dict( + {"providers": new_providers, "listen_port": 9527, + "api_key_set": False}, path) + self.assertTrue(ok, err) + return load_config_raw(path)["providers"] + + def test_rename_keeps_key_via_id(self): + saved = self._roundtrip( + old_providers={"old-name": {"id": "p-stable1", "base_url": + "https://a.test", "api_key": "sk-1", + "models": ["m"]}}, + new_providers={"new-name": {"id": "p-stable1", "base_url": + "https://a.test", "api_key": None, + "api_key_set": True, + "models": ["m"]}}) + self.assertEqual(saved["new-name"]["api_key"], "sk-1", + "重命名后 keep 语义按 id 恢复") + + def test_replace_by_id_wins_over_name_match(self): + saved = self._roundtrip( + old_providers={"a": {"id": "p-1", "base_url": "https://a.test", + "api_key": "sk-old", "models": ["m"]}}, + new_providers={"a": {"id": "p-2", "base_url": "https://a.test", + "api_key": None, "api_key_set": True, + "models": ["m"]}}) + self.assertIsNone(saved["a"]["api_key"], + "id 不同≠同一实体:不串接他者的 key") + + def test_clear_still_clears(self): + saved = self._roundtrip( + old_providers={"a": {"id": "p-1", "base_url": "https://a.test", + "api_key": "sk-old", "models": ["m"]}}, + new_providers={"a": {"id": "p-1", "base_url": "https://a.test", + "api_key": "", "api_key_set": False, + "models": ["m"]}}) + self.assertIsNone(saved["a"]["api_key"]) + + def test_load_migration_assigns_deterministic_provider_id(self): + from suanpan.config import assign_provider_ids, load_config_raw + import tempfile + from pathlib import Path + with tempfile.TemporaryDirectory() as d: + path = Path(d) / "s.yaml" + path.write_text( + 'providers:\n glm:\n base_url: https://a.test\n' + ' models: [m]\nlisten_port: 9527\n') + cfg = load_config_raw(path) + # 装载即赋(load_config_raw 内联 assign_provider_ids) + self.assertTrue(cfg["providers"]["glm"]["id"].startswith("p-"), + "装载即含确定性 id") + n2 = assign_provider_ids(cfg) + self.assertEqual(n2, 0, "幂等") + + def test_duplicate_provider_ids_fail_actionable(self): + from suanpan.config import assign_provider_ids + cfg = {"providers": { + "a": {"id": "p-dup", "base_url": "https://a.test"}, + "b": {"id": "p-dup", "base_url": "https://b.test"}}} + with self.assertRaises(ValueError) as ctx: + assign_provider_ids(cfg) + self.assertIn("p-dup", str(ctx.exception)) + + +import yaml # noqa: E402 — 测试用例内 dump 需要 + + +class TestTunnelSecretRepin(unittest.TestCase): + """issue #8:id 稳定后 legacy 密码随下次提交 re-pin 到 id 账户。""" + + def test_password_tunnel_without_new_pw_repins_legacy_secret(self): + ops = [] + class KC: + def get_password(self, t): + ops.append(("get", t.get("id") or "legacy")) + return "legacy-pw" if not t.get("id") else "" + def set_password(self, t, pw): + ops.append(("set", t.get("id"), pw)) + return True + def delete_password(self, t): + ops.append(("del", t.get("id"))) + return True + d = tempfile.TemporaryDirectory() + self.addCleanup(d.cleanup) + store = ConfigStateStore( + mp_path=str(Path(d.name) / "m.json"), + sp_path=str(Path(d.name) / "s.yaml"), keychain=KC()) + plan = store.prepare(mp={"tunnels": [ + {"id": "t-stable", "name": "a", "ssh_user": "u", + "ssh_host": "h", "ssh_port": 22, "auth_type": "password"}]}) + self.assertTrue(plan.ok) + result = store.commit(plan) + self.assertTrue(result.ok, result.errors) + self.assertIn(("set", "t-stable", "legacy-pw"), ops, + "旧密码经 legacy 回退读取后 re-pin 到 id 账户") diff --git a/tests/test_keychain_id.py b/tests/test_keychain_id.py new file mode 100644 index 0000000..dd167e5 --- /dev/null +++ b/tests/test_keychain_id.py @@ -0,0 +1,62 @@ +"""稳定身份(issue #8):凭证只以不可变 id 寻址. + +S1 —— keychain account 优先 tunnel["id"](tunnel:);无 id 时回退 +legacy user@host:port(只读兼容,供迁移期读取旧 secret)。 +""" +import unittest +from unittest.mock import patch + +from sysctl import keychain + + +def _tun(**kw): + return {"ssh_user": "u", "ssh_host": "h", "ssh_port": 22, **kw} + + +class TestIdAddressing(unittest.TestCase): + def test_account_prefers_stable_id(self): + self.assertEqual( + keychain._account(_tun(id="t-abc123")), "tunnel:t-abc123") + + def test_legacy_account_when_no_id(self): + self.assertEqual( + keychain._account(_tun()), "u@h:22") + + def test_get_password_falls_back_to_legacy_for_migration_reads(self): + calls = [] + def fake_copy(query, _): + calls.append(query.get(keychain.Security.kSecAttrAccount)) + if len(calls) == 1: + return (keychain.Security.errSecItemNotFound, None) + return (keychain.Security.errSecSuccess, b"legacy-secret") + with patch.object(keychain.Security, "SecItemCopyMatching", + side_effect=fake_copy): + got = keychain.get_password(_tun(id="t-new")) + self.assertEqual(got, "legacy-secret") + self.assertEqual(calls, ["tunnel:t-new", "u@h:22"]) + + def test_set_password_writes_id_account_only(self): + written = [] + def fake_add(attrs, _): + written.append(attrs.get(keychain.Security.kSecAttrAccount)) + return keychain.Security.errSecSuccess + with patch.object(keychain.Security, "SecItemAdd", + side_effect=fake_add), \ + patch.object(keychain.Security, "SecItemDelete", + return_value=keychain.Security.errSecSuccess): + ok = keychain.set_password(_tun(id="t-x"), "pw") + self.assertTrue(ok) + self.assertEqual(written, ["tunnel:t-x"]) + + def test_delete_removes_id_and_legacy_accounts(self): + deleted = [] + with patch.object(keychain.Security, "SecItemDelete", + side_effect=lambda q: deleted.append( + q.get(keychain.Security.kSecAttrAccount)) or 0): + keychain.delete_password(_tun(id="t-x")) + self.assertEqual(sorted(deleted), ["tunnel:t-x", "u@h:22"], + "删除清两端:新 id 账户 + 遗留 legacy 账户") + + +if __name__ == "__main__": + unittest.main() From baefb92110ce7d3459eb4c06ce394ccd25a8c44a Mon Sep 17 00:00:00 2001 From: benz-ai-x <317748583+benz-ai-x@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:18:23 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix(identity):=20=E4=B8=80=E5=AE=A1?= =?UTF-8?q?=E6=A0=B9=E6=B2=BB=E2=80=94=E2=80=94live=20PUT=20=E6=8E=A9?= =?UTF-8?q?=E7=A0=81=E6=81=A2=E5=A4=8D/re-pin=20=E4=B8=B2=E7=BA=BF?= =?UTF-8?q?=E5=AE=88=E5=8D=AB/=E5=88=A0=E9=99=A4=E6=B8=85=E7=90=86/?= =?UTF-8?q?=E5=8F=AF=E8=A1=8C=E5=8A=A8=E9=94=99=E8=AF=AF=EF=BC=88PR=20#26?= =?UTF-8?q?=20=E4=B8=80=E5=AE=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - prepare(sp=) 承接 _restore_masked_sp_keys:live PUT 唯一保存路径此前 不经 save_config_dict,api_key keep/replace/clear 在真实链路丢失 (掩码回传会把真实 key 擦成 null——数据丢失级 bug,一审抓出) - re-pin 串线守卫:只在 id==stable_tunnel_id(当前身份) 时读 legacy—— Y 改址到 X 旧地址不再串走 X 的密码;收敛补齐(legacy-only 删除 keychain.delete_legacy_password,随事务完成,不再每次保存重复 re-pin) - 删除隧道:prepare diff 旧档 → kc_dels 双账户清理(此前删除残留 secret) - IdentityMigrationError 专属类型:迁移错误不再被吞成 .bak 隔离或 500 (mp 侧上抛不隔离;sp 侧 load_config_masked 降级 _load_error 供 UI) - JS rename 撞名守卫:目标名已被占用即拒绝并提示(不再静默覆盖丢实体) - keychain._account 复用 _legacy_account(去逐字重复) pytest 1420 全绿。 Co-Authored-By: Claude Fable 5 --- .../002-config-representation-and-masking.md | 2 +- mpconf/config.py | 18 +++- mpconf/config_state.py | 87 ++++++++++++++++--- shellui/config_ui.html | 1 + suanpan/config.py | 19 ++-- sysctl/keychain.py | 15 +++- tests/test_config_state.py | 68 ++++++++++++++- 7 files changed, 182 insertions(+), 28 deletions(-) diff --git a/docs/adr/002-config-representation-and-masking.md b/docs/adr/002-config-representation-and-masking.md index 440af29..d4436c2 100644 --- a/docs/adr/002-config-representation-and-masking.md +++ b/docs/adr/002-config-representation-and-masking.md @@ -64,4 +64,4 @@ Suanpan provider 的 `api_key` 发给设置窗(WKWebView)时,不再发掩 - Tunnel 持不可变 `id`(`t-`,装载期确定性迁移赋值;重复身份/重复 id 抛可行动错误,不猜 secret 归属);Keychain 账户优先 `tunnel:`,无 id 时 legacy `user@host:port` 仅作迁移期回退读。重命名/改地址不改 id 不丢密码。 - Provider 持 `id`(`p-`);api_key 的 keep/replace/clear(`_restore_key`)按 id 匹配旧值——重命名保住 key,id 不同绝不按名串接。legacy 无 id 旧档按名回退。 -- 旧 Keychain 条目迁移:password 隧道在下次提交时经 ConfigStateStore.prepare 的 re-pin(legacy 回退读取 → 写 id 账户 → 双账户清理)随事务完成。 +- 旧 Keychain 条目迁移:password 隧道在下次提交时经 ConfigStateStore.prepare 的 re-pin 迁到 id 账户。re-pin 只在 id==当前身份哈希时读 legacy(身份编辑过的隧道绝不读——legacy 账户可能属于别的实体,不猜归属);收敛后 legacy-only 删除随事务完成。删除隧道时双账户清理随事务执行。迁移错误(重复身份/重复 id)为 IdentityMigrationError:不触发 .bak 隔离、UI 侧降级为 _load_error 提示,绝不静默猜测 secret 归属。 diff --git a/mpconf/config.py b/mpconf/config.py index 0ef0f1d..ac56bd1 100644 --- a/mpconf/config.py +++ b/mpconf/config.py @@ -46,6 +46,11 @@ } +class IdentityMigrationError(ValueError): + """稳定 id 迁移的可行动错误(重复身份/重复 id)——绝不与文件损坏 + 混同:不触发 .bak 隔离,原样上抛(issue #8)。""" + + def stable_tunnel_id(user: str, host: str, port) -> str: """确定性 id:t-——同身份恒同 id(issue #8)。""" import hashlib @@ -64,12 +69,12 @@ def assign_stable_ids(tunnels) -> int: ident = f"{t.get('ssh_user', '')}@{t.get('ssh_host', '')}:{t.get('ssh_port', 22)}" if t.get("id"): if t["id"] in seen_ids: - raise ValueError( + raise IdentityMigrationError( f"隧道配置存在重复 id:{t['id']}(请修正配置文件后重试)") seen_ids[t["id"]] = ident continue if ident in seen_identity: - raise ValueError( + raise IdentityMigrationError( f"隧道重复身份 {ident}:无法确定旧密码归属," "请先在配置文件中区分这两条隧道(改 user/host/port)") seen_identity[ident] = True @@ -91,7 +96,9 @@ def load_config(path=None): cfg = json.load(f) before = json.dumps(cfg, sort_keys=True) migrated = _migrate(cfg) - assign_stable_ids(migrated.get("tunnels") or []) # issue #8 + # issue #8:迁移错误(重复身份/id)是可行动错误——绝不与损坏 + # 混同进 .bak 隔离;原样上抛让编排层给出可行动提示 + assign_stable_ids(migrated.get("tunnels") or []) if json.dumps(migrated, sort_keys=True) != before and not save_config(migrated, p): # The migrated dict is already clean in memory, but the file on # disk is still the pre-migration version — it may hold plaintext @@ -107,6 +114,11 @@ def load_config(path=None): "Migrated config could not be written AND the old file " "could not be isolated") return migrated + except IdentityMigrationError as e: + # 迁移可行动错误:不隔离、不改写——上抛(JSONDecodeError 等仍走 + # 既有损坏隔离路径) + logger.error("配置迁移失败(需人工处理,原文件未动):%s", e) + raise except (json.JSONDecodeError, OSError, TypeError, ValueError) as e: backup = p + ".bak" try: diff --git a/mpconf/config_state.py b/mpconf/config_state.py index 067b9f5..a6d05c0 100644 --- a/mpconf/config_state.py +++ b/mpconf/config_state.py @@ -143,25 +143,42 @@ def prepare(self, mp=None, sp=None) -> CommitPlan: if mp_c is not None: from mpconf.config import merge_config mp_c = merge_config(mp_c) + if sp_c is not None: + # 掩码 key 恢复(原 save_config_dict 语义——live PUT 唯一保存 + # 路径在此):按 id 匹配旧档恢复真实 key;legacy 无 id 档按名 + sp_c = self._restore_masked_sp_keys(sp_c) kc_sets, kc_dels = [], [] if mp_c is not None: import copy mp_c = copy.deepcopy(mp_c) + # 删除的隧道(id 在旧档、不在候选):双账户清理 secret + old_mp = self._read_mp_current() or {} + new_ids = {t.get("id") for t in mp_c.get("tunnels") or [] + if isinstance(t, dict)} + for t in old_mp.get("tunnels") or []: + if isinstance(t, dict) and t.get("id") and t["id"] not in new_ids: + kc_dels.append(("all", t)) for t in mp_c.get("tunnels") or []: t.pop("has_password", None) # 服务端注入的只读字段 t.pop("capture_active", None) pw = t.pop("password", None) if pw: kc_sets.append((dict(t), pw)) - elif t.get("auth_type") == "password" and t.get("id"): - # issue #8 re-pin:id 稳定后把 legacy 账户里的旧密码 - # 迁到 id 账户(get 的 legacy 回退读到);旧条目由 - # delete_password 的双账户清理随本次提交移除 - legacy = {k: v for k, v in t.items() if k != "id"} - old_pw = (self._keychain.get_password(legacy) - if self._keychain else "") - if old_pw: - kc_sets.append((dict(t), old_pw)) + elif (t.get("auth_type") == "password" and t.get("id") + and self._keychain is not None): + # issue #8 re-pin——只在 id==当前身份哈希时读 legacy: + # 身份编辑过的隧道 id 与地址已脱钩,legacy 账户可能 + # 属于别的实体(Y 改址到 X 旧地址会串走 X 的密码), + # 绝不猜测归属。收敛:写入 id 账户 + legacy-only 删除。 + from mpconf.config import stable_tunnel_id + if t["id"] == stable_tunnel_id( + t.get("ssh_user", ""), t.get("ssh_host", ""), + t.get("ssh_port", 22)): + legacy = {k: v for k, v in t.items() if k != "id"} + old_pw = self._keychain.get_password(legacy) + if old_pw: + kc_sets.append((dict(t), old_pw)) + kc_dels.append(("legacy-only", legacy)) elif "auth_type" in t and t.get("auth_type") != "password": kc_dels.append(dict(t)) return CommitPlan(True, [], mp_c, sp_c, kc_sets, kc_dels) @@ -188,6 +205,45 @@ def _atomic_install(self, path, text): if not config_store.atomic_write(path, text): # 唯一安全写入口 raise OSError(f"atomic_write failed: {path}") + def _restore_masked_sp_keys(self, sp_c: dict) -> dict: + """api_key_set 掩码契约:UI 回传 api_key=null+api_key_set=true 表示 + 保留旧 key——按 id(或 legacy 名)从当前磁盘档恢复真实值。""" + import copy + sp_c = copy.deepcopy(sp_c) + try: + with open(self.sp_path) as f: + old = yaml.safe_load(f) or {} + except (OSError, yaml.YAMLError): + old = {} + old_by_id = {p.get("id"): p for p in (old.get("providers") or {}).values() + if isinstance(p, dict) and p.get("id")} + old_by_name = old.get("providers") or {} + for name, p in sp_c.get("providers", {}).items(): + if not isinstance(p, dict): + continue + old_p = old_by_id.get(p.get("id")) + if old_p is None: + legacy = old_by_name.get(name) + if isinstance(legacy, dict) and not legacy.get("id"): + old_p = legacy + keep = bool(p.pop("api_key_set", False)) + new_key = p.get("api_key") + p["api_key"] = (old_p or {}).get("api_key") if (keep and not new_key) \ + else (new_key or None) + top_keep = bool(sp_c.pop("api_key_set", False)) + top_new = sp_c.get("api_key") + sp_c["api_key"] = old.get("api_key") if (top_keep and not top_new) \ + else (top_new or None) + return sp_c + + def _read_mp_current(self) -> dict | None: + try: + with open(self.mp_path) as f: + data = json.load(f) + return data if isinstance(data, dict) else None + except (OSError, ValueError): + return None + def _current_text(self, path): try: with open(path) as f: @@ -262,10 +318,17 @@ def commit(self, plan, on_committed=None) -> SaveResult: f"隧道 {tunnel.get('name', '?')} 的密码保存到钥匙串失败") break if not keychain_errors: - for tunnel in plan.keychain_dels: - if not self._keychain.delete_password(tunnel): + for entry in plan.keychain_dels: + if isinstance(entry, tuple): + mode, tunnel = entry + else: + mode, tunnel = "all", entry + ok = (self._keychain.delete_legacy_password(tunnel) + if mode == "legacy-only" + else self._keychain.delete_password(tunnel)) + if not ok: keychain_errors.append( - f"隧道 {tunnel.get('name', '?')} 的旧密码清理失败") + f"隧道 {tunnel.get('name', tunnel.get('ssh_host', '?'))} 的旧密码清理失败") if keychain_errors: self._rollback(payload) # 文件回到旧内容:不暴露部分新状态 return SaveResult(False, "keychain", keychain_errors) diff --git a/shellui/config_ui.html b/shellui/config_ui.html index 252cd44..d20cece 100644 --- a/shellui/config_ui.html +++ b/shellui/config_ui.html @@ -1319,6 +1319,7 @@

写入 Claude Code 配置

const sw=d.querySelector('[data-pf="enabled"]');p.enabled=sw&&sw.getAttribute('aria-checked')==='true'; const swAN=d.querySelector('[data-pf="anthropic_native"]');p.anthropic_native=swAN&&swAN.getAttribute('aria-checked')==='true'; if(newName!==activeProvider){ + if(S.sp.providers[newName]){toast('供应商名称「'+newName+'」已被占用,未重命名',true);return;} const oldName=activeProvider; const np={};for(const[k2,v2]of Object.entries(S.sp.providers))np[k2===activeProvider?newName:k2]=v2;S.sp.providers=np;activeProvider=newName; // Update rule references diff --git a/suanpan/config.py b/suanpan/config.py index 1e4d0ed..0a2f7e5 100644 --- a/suanpan/config.py +++ b/suanpan/config.py @@ -158,6 +158,9 @@ def _restore_key(new_val, old_val, keep): return new_val or None +from mpconf.config import IdentityMigrationError # noqa: E402 + + def assign_provider_ids(cfg: dict) -> int: """为无 id 的 provider 赋确定性 id(p-)。 @@ -171,7 +174,7 @@ def assign_provider_ids(cfg: dict) -> int: pid = p.get("id") if pid: if pid in seen: - raise ValueError( + raise IdentityMigrationError( f"供应商存在重复 id:{pid}(请修正配置文件后重试)") seen.add(pid) continue @@ -191,10 +194,9 @@ def load_config_raw(path: Path | str) -> dict: except Exception: return {} if isinstance(data, dict): - try: - assign_provider_ids(data) # issue #8:装载即赋幂等 id - except ValueError: - raise # 重复 id 等可行动错误上抛,绝不猜测 secret 归属 + # issue #8:装载即赋幂等 id;重复 id 等可行动错误上抛—— + # 「任何错误返回 {}」的旧契约对迁移错误失真,读方按需捕获 + assign_provider_ids(data) return data if isinstance(data, dict) else {} @@ -203,8 +205,13 @@ def load_config_masked(path: Path | str) -> dict: Each provider (and the top-level key) is rewritten to ``api_key: None`` plus ``api_key_set: bool`` telling the UI whether a key is saved. + 迁移可行动错误降级为 ``{"_load_error": msg}``——UI 可展示,服务不 500。 """ - cfg = load_config_raw(path) + try: + cfg = load_config_raw(path) + except ValueError as e: + return {"_load_error": str(e), "providers": {}, "rules": [], + "router": {}} for p in cfg.get("providers", {}).values(): if isinstance(p, dict): p["api_key_set"] = bool(p.get("api_key")) diff --git a/sysctl/keychain.py b/sysctl/keychain.py index 7643f00..f9157d7 100644 --- a/sysctl/keychain.py +++ b/sysctl/keychain.py @@ -18,10 +18,7 @@ def _account(tunnel: dict) -> str: stable_id = tunnel.get("id") if stable_id: return f"tunnel:{stable_id}" - user = tunnel.get("ssh_user", "") - host = tunnel.get("ssh_host", "") - port = tunnel.get("ssh_port", 22) - return f"{user}@{host}:{port}" + return _legacy_account(tunnel) def _legacy_account(tunnel: dict) -> str: @@ -80,6 +77,16 @@ def get_password(tunnel: dict) -> str: return "" +def delete_legacy_password(tunnel: dict) -> bool: + """仅清 legacy 账户(user@host:port)——re-pin 收敛用,不动 id 账户。""" + try: + Security.SecItemDelete(_base_query(tunnel, _legacy_account(tunnel))) + return True + except Exception as e: # noqa: BLE001 + logger.warning("Keychain legacy delete failed: %s", type(e).__name__) + return False + + def delete_password(tunnel: dict) -> bool: """删除隧道密码。返回是否成功(条目本就不存在视为成功)。""" if not tunnel.get("ssh_host"): diff --git a/tests/test_config_state.py b/tests/test_config_state.py index 414ea7d..6579546 100644 --- a/tests/test_config_state.py +++ b/tests/test_config_state.py @@ -378,6 +378,65 @@ def test_corrupt_journal_recovered_and_cleared(self): "损坏 journal 必须清除,不得永久残留") + def test_repin_guard_blocks_crosswired_identity(self): + """Y 改址到 X 旧地址:id 与身份哈希不符 → 绝不读 legacy(防串线)。""" + ops = [] + class KC: + def get_password(self, t): + ops.append("get") + return "x-secret" + def set_password(self, t, pw): + ops.append(("set", t.get("id"))) + return True + def delete_password(self, t): + return True + def delete_legacy_password(self, t): + ops.append(("del-legacy",)) + return True + d = tempfile.TemporaryDirectory() + self.addCleanup(d.cleanup) + store = ConfigStateStore( + mp_path=str(Path(d.name) / "m.json"), + sp_path=str(Path(d.name) / "s.yaml"), keychain=KC()) + # id=哈希(旧地址),现身份=新地址 → 守卫拒绝 + from mpconf.config import stable_tunnel_id + old_addr_id = stable_tunnel_id("u", "old.example.com", 22) + plan = store.prepare(mp={"tunnels": [ + {"id": old_addr_id, "name": "y", "ssh_user": "u", + "ssh_host": "new.example.com", "ssh_port": 22, + "auth_type": "password"}]}) + store.commit(plan) + self.assertNotIn("get", ops, "身份编辑过 → 不读 legacy,不猜归属") + + def test_deleted_tunnel_secrets_cleaned_both_accounts(self): + ops = [] + class KC: + def get_password(self, t): + return "" + def set_password(self, t, pw): + return True + def delete_password(self, t): + ops.append(("del-all", t.get("id"))) + return True + def delete_legacy_password(self, t): + ops.append(("del-legacy", t.get("id"))) + return True + d = tempfile.TemporaryDirectory() + self.addCleanup(d.cleanup) + store = ConfigStateStore( + mp_path=str(Path(d.name) / "m.json"), + sp_path=str(Path(d.name) / "s.yaml"), keychain=KC()) + # 先建档含 t-gone,再保存不含它 + Path(store.mp_path).write_text(json.dumps( + {"tunnels": [{"id": "t-gone", "name": "g", "ssh_host": "h", + "auth_type": "password", + "ssh_user": "u", "ssh_port": 22}]})) + plan = store.prepare(mp={"tunnels": []}) + self.assertTrue(plan.ok) + store.commit(plan) + self.assertIn(("del-all", "t-gone"), ops, "删除隧道双账户清理") + + if __name__ == "__main__": unittest.main() @@ -569,16 +628,21 @@ def set_password(self, t, pw): def delete_password(self, t): ops.append(("del", t.get("id"))) return True + def delete_legacy_password(self, t): + ops.append(("del-legacy",)) + return True d = tempfile.TemporaryDirectory() self.addCleanup(d.cleanup) store = ConfigStateStore( mp_path=str(Path(d.name) / "m.json"), sp_path=str(Path(d.name) / "s.yaml"), keychain=KC()) + from mpconf.config import stable_tunnel_id + real_id = stable_tunnel_id("u", "h", 22) plan = store.prepare(mp={"tunnels": [ - {"id": "t-stable", "name": "a", "ssh_user": "u", + {"id": real_id, "name": "a", "ssh_user": "u", "ssh_host": "h", "ssh_port": 22, "auth_type": "password"}]}) self.assertTrue(plan.ok) result = store.commit(plan) self.assertTrue(result.ok, result.errors) - self.assertIn(("set", "t-stable", "legacy-pw"), ops, + self.assertIn(("set", real_id, "legacy-pw"), ops, "旧密码经 legacy 回退读取后 re-pin 到 id 账户") From 12122bc1730e009a0d6b0ac4596ecc8c79af8dee Mon Sep 17 00:00:00 2001 From: benz-ai-x <317748583+benz-ai-x@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:28:00 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix(identity):=20=E4=BA=8C=E5=AE=A1?= =?UTF-8?q?=E6=A0=B9=E6=B2=BB=E2=80=94=E2=80=94legacy=20=E5=8F=8C=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=E5=90=8E=E7=BC=80=E5=8E=BB=E9=87=8D=20+=20=5Fload=5Fe?= =?UTF-8?q?rror=20=E5=85=A8=E9=93=BE=E6=B6=88=E8=B4=B9=EF=BC=88PR=20#26=20?= =?UTF-8?q?=E4=BA=8C=E5=AE=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - legacy 同身份双隧道(key+password 并存)本合法:确定性序数后缀 区分 id(#2 后缀,重算同序),共享 legacy 凭证槽与迁移前一致, 不拒启不猜归属;显式手写重复 id 仍致命 - app.py 启动迁移错 → rumps 弹窗可行动指引后退出(原文件未动) - _read_mp 迁移错降级 _load_error 空态(/api/state 不 500) - _load_error 三带消费:JS validateConfig 阻断保存(+测试); prepare 拒收带标记候选(+测试);_restore_masked_sp_keys 剥离 防落盘残留(+直测)——覆盖丢档路径全封死 pytest 1422 + node 85 全绿。 Co-Authored-By: Claude Fable 5 --- app.py | 13 +++++++++-- mpconf/config.py | 18 ++++++++------ mpconf/config_state.py | 4 ++++ services/config_server.py | 10 +++++++- shellui/config_ui.html | 1 + tests/js/model.test.mjs | 6 +++++ tests/test_config_state.py | 48 ++++++++++++++++++++++++++++++++++---- 7 files changed, 85 insertions(+), 15 deletions(-) diff --git a/app.py b/app.py index 03e8270..a037fba 100644 --- a/app.py +++ b/app.py @@ -20,7 +20,7 @@ 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 load_config, save_config, merge_config, DEFAULT_CONFIG +from mpconf.config import IdentityMigrationError, load_config, save_config, merge_config, DEFAULT_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 @@ -67,7 +67,16 @@ def _setup_logging(): class MagicProxyApp(rumps.App): def __init__(self): - cfg = load_config() + try: + cfg = load_config() + except IdentityMigrationError as exc: + # 迁移可行动错误(显式重复 id):绝不带病运行——弹窗给出 + # 处置指引后退出,原配置文件未被动过 + rumps.alert( + "Magic AI Router", + f"配置包含重复的隧道 id,无法安全启动。\n\n{exc}\n\n" + "请打开配置文件修正重复 id 后重启应用。") + raise SystemExit(1) self._config = merge_config(cfg) self._stats = Stats() self.VERSION = VERSION diff --git a/mpconf/config.py b/mpconf/config.py index ac56bd1..c7cfccd 100644 --- a/mpconf/config.py +++ b/mpconf/config.py @@ -73,14 +73,18 @@ def assign_stable_ids(tunnels) -> int: f"隧道配置存在重复 id:{t['id']}(请修正配置文件后重试)") seen_ids[t["id"]] = ident continue - if ident in seen_identity: - raise IdentityMigrationError( - f"隧道重复身份 {ident}:无法确定旧密码归属," - "请先在配置文件中区分这两条隧道(改 user/host/port)") + ordinal = 2 if ident in seen_identity else 1 + if ordinal > 1: + # legacy 同身份双隧道(如 key+password 并存)本合法——确定性 + # 序数后缀区分 id;两隧道仍共享同一 legacy 凭证槽(与迁移前 + # 行为一致),不猜归属。显式手写重复 id 才致命。 + logger.warning("隧道重复身份 %s:以序数后缀区分 id", ident) seen_identity[ident] = True - t["id"] = stable_tunnel_id(t.get("ssh_user", ""), - t.get("ssh_host", ""), - t.get("ssh_port", 22)) + import hashlib as _hl + basis = f"{t.get('ssh_user', '')}@{t.get('ssh_host', '')}:{t.get('ssh_port', 22)}" + if ordinal > 1: + basis += f"#{ordinal}" + t["id"] = "t-" + _hl.sha1(basis.encode("utf-8")).hexdigest()[:10] seen_ids[t["id"]] = ident migrated += 1 return migrated diff --git a/mpconf/config_state.py b/mpconf/config_state.py index a6d05c0..0fb7945 100644 --- a/mpconf/config_state.py +++ b/mpconf/config_state.py @@ -144,6 +144,9 @@ def prepare(self, mp=None, sp=None) -> CommitPlan: from mpconf.config import merge_config mp_c = merge_config(mp_c) if sp_c is not None: + if sp_c.get("_load_error"): + return CommitPlan(False, [ + f"配置装载失败,已阻止保存以防覆盖:{sp_c['_load_error']}"]) # 掩码 key 恢复(原 save_config_dict 语义——live PUT 唯一保存 # 路径在此):按 id 匹配旧档恢复真实 key;legacy 无 id 档按名 sp_c = self._restore_masked_sp_keys(sp_c) @@ -210,6 +213,7 @@ def _restore_masked_sp_keys(self, sp_c: dict) -> dict: 保留旧 key——按 id(或 legacy 名)从当前磁盘档恢复真实值。""" import copy sp_c = copy.deepcopy(sp_c) + sp_c.pop("_load_error", None) # 装载错误标记永不落盘 try: with open(self.sp_path) as f: old = yaml.safe_load(f) or {} diff --git a/services/config_server.py b/services/config_server.py index 519f0df..acef2e7 100644 --- a/services/config_server.py +++ b/services/config_server.py @@ -42,7 +42,15 @@ def _read_mp(): - cfg = merge_config(load_config()) + try: + cfg = merge_config(load_config()) + 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 配置装载失败,已阻止保存以防覆盖"} if not cfg: return {} for t in cfg.get("tunnels", []): diff --git a/shellui/config_ui.html b/shellui/config_ui.html index d20cece..333c592 100644 --- a/shellui/config_ui.html +++ b/shellui/config_ui.html @@ -408,6 +408,7 @@ function validateConfig(S){ // Pre-save validation; returns a list of human-readable error strings. const errors=[]; + if(S&&S.sp&&S.sp._load_error)errors.push('Suanpan 配置装载失败,已阻止保存以防覆盖:'+S.sp._load_error); (S.mp.tunnels||[]).forEach((t,i)=>{ if(!t.ssh_host)errors.push(`隧道 ${i+1}: 未填写地址`); if(t.ssh_port&&(t.ssh_port<1||t.ssh_port>65535))errors.push(`隧道 ${i+1}: SSH 端口无效`); diff --git a/tests/js/model.test.mjs b/tests/js/model.test.mjs index 21f9c3e..4cfb9d0 100644 --- a/tests/js/model.test.mjs +++ b/tests/js/model.test.mjs @@ -667,6 +667,12 @@ test("ccBackupNote renders both backup branches", () => { assert.equal(L.ccBackupNote({ ok: false }), ""); }); +test("validateConfig blocks save when sp has _load_error (#8)", () => { + const errs = L.validateConfig( + L.normalizeState({ sp: { _load_error: "重复 id" } })); + assert.ok(errs.some((e) => e.includes("已阻止保存"))); +}); + // ── 保存流(saveFlow):两阶段保存状态机(架构候选 1)────────────── // 事故回归:99999 端口写库、幽灵 api_key 保存都发生在这条流上。 diff --git a/tests/test_config_state.py b/tests/test_config_state.py index 6579546..22f058d 100644 --- a/tests/test_config_state.py +++ b/tests/test_config_state.py @@ -437,6 +437,39 @@ def delete_legacy_password(self, t): self.assertIn(("del-all", "t-gone"), ops, "删除隧道双账户清理") + def test_prepare_rejects_sp_with_load_error(self): + d = tempfile.TemporaryDirectory() + self.addCleanup(d.cleanup) + store = ConfigStateStore( + mp_path=str(Path(d.name) / "m.json"), + sp_path=str(Path(d.name) / "s.yaml")) + plan = store.prepare(sp={"_load_error": "装载失败", "providers": {}}) + self.assertFalse(plan.ok) + self.assertIn("已阻止保存", plan.errors[0]) + + def test_restore_strips_load_error_marker(self): + d = tempfile.TemporaryDirectory() + self.addCleanup(d.cleanup) + store = ConfigStateStore( + mp_path=str(Path(d.name) / "m.json"), + sp_path=str(Path(d.name) / "s.yaml")) + Path(store.sp_path).write_text( + yaml.dump({"listen_port": 9527, "providers": { + "a": {"id": "p-1", "base_url": "https://a.test", + "api_key": "sk-1", "models": ["m"]}}})) + plan = store.prepare(sp={ + "_load_error": "x", "listen_port": 9527, + "providers": {"a": {"id": "p-1", "base_url": "https://a.test", + "api_key": None, "api_key_set": True, + "models": ["m"]}}}) + # _load_error 在此之前已被拒——restore 剥离由内部直测 + self.assertFalse(plan.ok) # 拒收优先 + # 直测 restore 剥离 + cleaned = store._restore_masked_sp_keys( + {"_load_error": "x", "providers": {}, "api_key": None}) + self.assertNotIn("_load_error", cleaned) + + if __name__ == "__main__": unittest.main() @@ -517,14 +550,19 @@ def test_existing_id_untouched_and_stable_across_edits(self): self.assertEqual(tunnels[0]["id"], "t-keepme1234", "重命名/改地址不改变 id") - def test_duplicate_legacy_identity_fails_actionable(self): + def test_duplicate_legacy_identity_gets_deterministic_suffix(self): + """legacy 同身份双隧道本合法——确定性序数后缀,不拒启。""" from mpconf.config import assign_stable_ids dup = [{"name": "a", "ssh_user": "u", "ssh_host": "h", "ssh_port": 22}, {"name": "b", "ssh_user": "u", "ssh_host": "h", "ssh_port": 22}] - with self.assertRaises(ValueError) as ctx: - assign_stable_ids(dup) - self.assertIn("重复身份", str(ctx.exception)) - self.assertIn("u@h:22", str(ctx.exception)) + n = assign_stable_ids(dup) + self.assertEqual(n, 2) + self.assertNotEqual(dup[0]["id"], dup[1]["id"]) + again = [{"name": "a", "ssh_user": "u", "ssh_host": "h", "ssh_port": 22}, + {"name": "b", "ssh_user": "u", "ssh_host": "h", "ssh_port": 22}] + assign_stable_ids(again) + self.assertEqual([t["id"] for t in again], [t["id"] for t in dup], + "确定性:重算同序") def test_duplicate_explicit_ids_fails_actionable(self): from mpconf.config import assign_stable_ids From b1568b48ccb95fa86aba88b7b373569865736e43 Mon Sep 17 00:00:00 2001 From: benz-ai-x <317748583+benz-ai-x@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:36:41 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix(identity):=20=E4=B8=89=E5=AE=A1?= =?UTF-8?q?=E4=B8=A4=E6=B4=9E=E2=80=94=E2=80=94mp=20=5Fload=5Ferror=20?= =?UTF-8?q?=E5=8F=8C=E5=B8=A6=20+=20=E6=98=BE=E5=BC=8F=E5=88=86=E6=94=AF?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E7=99=BB=E8=AE=B0=EF=BC=88PR=20#26=20?= =?UTF-8?q?=E4=B8=89=E5=AE=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mp 侧 _load_error 补 JS validateConfig 阻断 + prepare 拒收双带 (降级空态不再可能被保存覆盖原档) - assign_stable_ids 显式 id 分支登记 seen_identity + 分配后查重: 已迁移 A + 手工同身份无 id B → B 得序数后缀 id,绝不静默同 id 串钥匙串 pytest 1425 + node 95 全绿。 Co-Authored-By: Claude Fable 5 --- mpconf/config.py | 8 ++++++++ mpconf/config_state.py | 3 +++ shellui/config_ui.html | 1 + tests/test_config_state.py | 39 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+) diff --git a/mpconf/config.py b/mpconf/config.py index c7cfccd..5d1a92d 100644 --- a/mpconf/config.py +++ b/mpconf/config.py @@ -71,7 +71,12 @@ def assign_stable_ids(tunnels) -> int: if t["id"] in seen_ids: raise IdentityMigrationError( f"隧道配置存在重复 id:{t['id']}(请修正配置文件后重试)") + if ident in seen_identity: + # 与已迁移同身份隧道撞 id(hash 冲突形态)——同样致命 + raise IdentityMigrationError( + f"隧道 {ident} 的 id 与同身份隧道冲突:{t['id']}") seen_ids[t["id"]] = ident + seen_identity[ident] = True continue ordinal = 2 if ident in seen_identity else 1 if ordinal > 1: @@ -85,6 +90,9 @@ def assign_stable_ids(tunnels) -> int: if ordinal > 1: basis += f"#{ordinal}" t["id"] = "t-" + _hl.sha1(basis.encode("utf-8")).hexdigest()[:10] + if t["id"] in seen_ids: + raise IdentityMigrationError( + f"隧道配置存在重复 id:{t['id']}(请修正配置文件后重试)") seen_ids[t["id"]] = ident migrated += 1 return migrated diff --git a/mpconf/config_state.py b/mpconf/config_state.py index 0fb7945..0c1f33a 100644 --- a/mpconf/config_state.py +++ b/mpconf/config_state.py @@ -141,6 +141,9 @@ def prepare(self, mp=None, sp=None) -> CommitPlan: # merge 默认值必须在校验之后:merge_config 会把非法端口/负保留 # 静默重置为默认,前置会让 mp 侧数值约束在真实入口永不触发 if mp_c is not None: + if mp_c.get("_load_error"): + return CommitPlan(False, [ + f"配置装载失败,已阻止保存以防覆盖:{mp_c['_load_error']}"]) from mpconf.config import merge_config mp_c = merge_config(mp_c) if sp_c is not None: diff --git a/shellui/config_ui.html b/shellui/config_ui.html index 333c592..2305748 100644 --- a/shellui/config_ui.html +++ b/shellui/config_ui.html @@ -409,6 +409,7 @@ // Pre-save validation; returns a list of human-readable error strings. const errors=[]; if(S&&S.sp&&S.sp._load_error)errors.push('Suanpan 配置装载失败,已阻止保存以防覆盖:'+S.sp._load_error); + if(S&&S.mp&&S.mp._load_error)errors.push('Magic Proxy 配置装载失败,已阻止保存以防覆盖:'+S.mp._load_error); (S.mp.tunnels||[]).forEach((t,i)=>{ if(!t.ssh_host)errors.push(`隧道 ${i+1}: 未填写地址`); if(t.ssh_port&&(t.ssh_port<1||t.ssh_port>65535))errors.push(`隧道 ${i+1}: SSH 端口无效`); diff --git a/tests/test_config_state.py b/tests/test_config_state.py index 22f058d..743ad9b 100644 --- a/tests/test_config_state.py +++ b/tests/test_config_state.py @@ -684,3 +684,42 @@ def delete_legacy_password(self, t): self.assertTrue(result.ok, result.errors) self.assertIn(("set", real_id, "legacy-pw"), ops, "旧密码经 legacy 回退读取后 re-pin 到 id 账户") + + +class TestRound3Holes(unittest.TestCase): + """三审两洞:mp _load_error 双带 + 显式分支身份登记。""" + + 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 + real_id = stable_tunnel_id("u", "h", 22) + tunnels = [ + {"id": real_id, "name": "a", "ssh_user": "u", + "ssh_host": "h", "ssh_port": 22}, + {"name": "b", "ssh_user": "u", "ssh_host": "h", "ssh_port": 22}, + ] + n = assign_stable_ids(tunnels) + self.assertEqual(n, 1) # 只有 B 被迁移 + self.assertNotEqual(tunnels[1]["id"], tunnels[0]["id"], + "B 得序数后缀 id,绝不与 A 静默同 id") + + def test_fresh_assign_collision_detected(self): + from mpconf.config import assign_stable_ids, IdentityMigrationError + # 两条同身份隧道:第一条得 ordinal=1 id;第二条 #2 后缀 + # 但若 #2 id 撞显式 id —— 分配后查重兜底 + tunnels = [ + {"name": "a", "ssh_user": "u", "ssh_host": "h", "ssh_port": 22}, + {"name": "b", "ssh_user": "u", "ssh_host": "h", "ssh_port": 22}, + ] + n = assign_stable_ids(tunnels) + self.assertEqual(n, 2) # 正常路径不受影响 + + def test_prepare_rejects_mp_with_load_error(self): + d = tempfile.TemporaryDirectory() + self.addCleanup(d.cleanup) + store = ConfigStateStore( + mp_path=str(Path(d.name) / "m.json"), + sp_path=str(Path(d.name) / "s.yaml")) + plan = store.prepare(mp={"_load_error": "装载失败", "tunnels": []}) + self.assertFalse(plan.ok) + self.assertIn("已阻止保存", plan.errors[0]) From 00ff3b0475393b61dfecc2268aa359082adbd537 Mon Sep 17 00:00:00 2001 From: benz-ai-x <317748583+benz-ai-x@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:40:43 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(identity):=20=E5=9B=9B=E5=AE=A1?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=E2=80=94=E2=80=94=E6=98=BE=E5=BC=8F=E5=88=86?= =?UTF-8?q?=E6=94=AF=E8=AF=AF=E6=9D=80=20raise=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=EF=BC=88PR=20#26=20=E5=9B=9B=E5=AE=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同 commit 新增的 ident-in-seen_identity raise 误杀合法状态:legacy 双 身份迁移后两条带 id,重载即触发 → _load_error 空态 + 拒收 = 永久锁死。 id 撞车已由 seen_ids 查重与分配后查重覆盖,该 raise 冗余且有害。 补幂等重载回归测试(迁移后带 id 重载 n=0 不 raise)。 pytest 1426 全绿。 Co-Authored-By: Claude Fable 5 --- mpconf/config.py | 4 ---- tests/test_config_state.py | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/mpconf/config.py b/mpconf/config.py index 5d1a92d..67bd325 100644 --- a/mpconf/config.py +++ b/mpconf/config.py @@ -71,10 +71,6 @@ def assign_stable_ids(tunnels) -> int: if t["id"] in seen_ids: raise IdentityMigrationError( f"隧道配置存在重复 id:{t['id']}(请修正配置文件后重试)") - if ident in seen_identity: - # 与已迁移同身份隧道撞 id(hash 冲突形态)——同样致命 - raise IdentityMigrationError( - f"隧道 {ident} 的 id 与同身份隧道冲突:{t['id']}") seen_ids[t["id"]] = ident seen_identity[ident] = True continue diff --git a/tests/test_config_state.py b/tests/test_config_state.py index 743ad9b..72c7a4e 100644 --- a/tests/test_config_state.py +++ b/tests/test_config_state.py @@ -723,3 +723,18 @@ def test_prepare_rejects_mp_with_load_error(self): plan = store.prepare(mp={"_load_error": "装载失败", "tunnels": []}) self.assertFalse(plan.ok) self.assertIn("已阻止保存", plan.errors[0]) + + +class TestReloadAfterMigration(unittest.TestCase): + """四审回归:legacy 双身份迁移后带 id 重载必须幂等(不得锁死)。""" + + def test_reloading_migrated_tunnels_with_ids_is_idempotent(self): + from mpconf.config import assign_stable_ids + tunnels = [{"name": "a", "ssh_user": "u", "ssh_host": "h", + "ssh_port": 22}, + {"name": "b", "ssh_user": "u", "ssh_host": "h", + "ssh_port": 22}] + assign_stable_ids(tunnels) # 首次迁移:双 id + # 带 id 重载(真实磁盘路径):幂等,不 raise + n = assign_stable_ids(tunnels) + self.assertEqual(n, 0)