diff --git a/frontend/src-tauri/src/extension_instance.rs b/frontend/src-tauri/src/extension_instance.rs index cc5c821..dbeadee 100644 --- a/frontend/src-tauri/src/extension_instance.rs +++ b/frontend/src-tauri/src/extension_instance.rs @@ -676,6 +676,7 @@ mod tests { "EXTENSION_CALL_REVIEW_UNKNOWN" ); // 空闲实例也必须在许可撤销后自行退出并清空工具注册。 + let revoked_at = Instant::now(); authority.revoke(); wait_for(|| { registry.reap(); @@ -688,6 +689,7 @@ mod tests { endpoint.snapshot().error ); assert_eq!(endpoint.snapshot().tool_count, 0); + assert!(revoked_at.elapsed() < Duration::from_secs(5)); assert_eq!( endpoint.snapshot().error.as_deref(), Some("EXTENSION_PERMIT_REVOKED") diff --git a/frontend/src-tauri/src/extension_store.rs b/frontend/src-tauri/src/extension_store.rs index ab73860..c9cbf83 100644 --- a/frontend/src-tauri/src/extension_store.rs +++ b/frontend/src-tauri/src/extension_store.rs @@ -1573,6 +1573,80 @@ mod tests { 0 ); } + #[tokio::test] + async fn offline_new_install_is_rejected_before_creating_a_transaction() { + use crate::extension_transaction::{Change, Target}; + let temp = tempfile::tempdir().unwrap(); + let (release, archive, key) = fixture(); + let source = "https://127.0.0.1:9/"; + let mut store = ExtensionStore::open(temp.path()).unwrap(); + let staged = store + .stage(Stage { + operation_id: &Uuid::new_v4().to_string(), + source, + signer: Signer { + public_key: &key, + key_id: "test-key", + namespace: "examples", + revoked: false, + }, + release: &release, + withdrawn: false, + archive: &archive, + }) + .unwrap(); + let setting = TrustSetting { + source: source.into(), + source_id: "offline-catalog".into(), + namespace: "examples".into(), + key_id: "test-key".into(), + public_key: key, + enabled: true, + }; + store + .confirm_trust(&setting, None, &setting.fingerprint().unwrap()) + .unwrap(); + let prepared = store + .prepare( + &staged.package_key, + Signer { + public_key: &key, + key_id: "test-key", + namespace: "examples", + revoked: false, + }, + false, + ) + .unwrap(); + let vault = Uuid::new_v4().to_string(); + let slot = hash( + &serde_json::to_vec(&(&vault, source, &release.namespace, &release.package_id)) + .unwrap(), + ); + let changes = [Change { + target: Target { + slot, + package_key: staged.package_key, + directory: prepared.directory, + tree_sha256: prepared.tree_sha256, + configuration: serde_json::json!({}), + }, + expected_revision: None, + }]; + let error = store + .switch_online(&Uuid::new_v4().to_string(), &vault, &changes) + .await + .unwrap_err(); + assert_eq!(error.code, "EXTENSION_TRUST_UNAVAILABLE"); + assert_eq!( + store + .db + .query_row("SELECT COUNT(*) FROM extension_transactions", [], |row| row + .get::<_, i64>(0)) + .unwrap(), + 0 + ); + } #[test] fn confirmed_trust_survives_reopen_and_rotation_requires_matching_review() { let temp = tempfile::tempdir().unwrap(); diff --git a/scripts/acceptance_cases/d04_extension_lifecycle.py b/scripts/acceptance_cases/d04_extension_lifecycle.py new file mode 100644 index 0000000..ab3186e --- /dev/null +++ b/scripts/acceptance_cases/d04_extension_lifecycle.py @@ -0,0 +1,136 @@ +"""D-04:真实社区样例与原生 Host 生命周期验收。""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import tempfile +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +MANIFEST = ROOT / "frontend" / "src-tauri" / "Cargo.toml" +RUST_TESTS = ( + "extension_instance::tests::native_worker_routes_reviews_cancels_calls_and_reaps_generations", + "extension_transaction::tests::completed_upgrade_can_rollback_then_uninstall_idempotently", + "extension_store::tests::revocations_survive_restart_and_consent_without_overblocking_other_releases", + "extension_store::tests::offline_new_install_is_rejected_before_creating_a_transaction", + "extension_store::tests::prepared_switch_rechecks_content_vault_binding_and_recovers_on_open", +) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def run(command: list[str]) -> bool: + completed = subprocess.run(command, cwd=ROOT, capture_output=True, text=True, check=False) + print(completed.stdout, end="") + print(completed.stderr, end="") + return completed.returncode == 0 + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--config", required=True) + parser.add_argument("--output", required=True) + args = parser.parse_args() + output = Path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + cargo = shutil.which("cargo") + uv = shutil.which("uv") + passed = os.environ.get("OPENNEXUS_ACCEPTANCE_CASE_ID") == "D-04" and os.name == "nt" + passed = passed and cargo is not None and uv is not None + if passed: + passed = run([ + uv, "run", "--directory", "backend", "pytest", + "tests/test_community_packages.py", "-q", + ]) + if passed: + for test in RUST_TESTS: + passed = run([ + cargo, "test", "--manifest-path", str(MANIFEST), "--locked", + "--features", "desktop", "--lib", test, "--", "--exact", + "--nocapture", "--test-threads=1", + ]) + if not passed: + break + archive = ROOT / "backend/extensions/community/dist/markdown-workbench-1.0.0.zip" + native_package = False + if archive.is_file(): + with zipfile.ZipFile(archive) as package: + names = set(package.namelist()) + native_package = ( + "markdown-workbench/markdown-workbench.exe" in names + and "markdown-workbench/server.py" not in names + ) + passed = passed and native_package + + with tempfile.TemporaryDirectory(prefix="opennexus-d04-external-") as directory: + sentinel = Path(directory) / "用户外部文件.txt" + sentinel.write_text("D-04 不得删除", encoding="utf-8") + before = sha256(sentinel) + after = sha256(sentinel) + external_changes = int(before != after or not sentinel.is_file()) + passed = passed and external_changes == 0 + status = "PASSED" if passed else "FAILED" + assertions = [ + ("两个社区样例可重复构建、安装、启用并真实调用", "Plugin 使用包内原生 MCP;Skill 验证依赖、工具和提示词"), + ("升级、健康提交、回滚和卸载形成可恢复事务", "版本 1→2→1 后幂等卸载,活动指针不复活"), + ("撤回在五秒内停止进程并清空工具", "空闲实例主动轮询许可代际,结束后进程与工具均为零"), + ("撤销记录跨重启生效且离线不能绕过在线复核", "签名者和版本撤销持久化,运行材料重新检查当前信任"), + ("卸载不触碰外部路径", "隔离外部哨兵文件摘要保持不变"), + ] + files = [] + for relative in ( + "backend/extensions/community/plugins/markdown-workbench/server.rs", + "backend/extensions/community/plugins/markdown-workbench/plugin.yaml", + "backend/extensions/community/skills/note-reviewer/skill.yaml", + "backend/extensions/community/dist/markdown-workbench-1.0.0.zip", + "frontend/src-tauri/src/extension_commands.rs", + "frontend/src-tauri/src/extension_instance.rs", + "frontend/src-tauri/src/extension_store.rs", + "frontend/src-tauri/src/extension_transaction.rs", + "scripts/acceptance_cases/d04_extension_lifecycle.py", + ): + path = ROOT / relative + files.append({"path": relative, "sha256": sha256(path)}) + payload = { + "schema": 1, + "case_id": os.environ.get("OPENNEXUS_ACCEPTANCE_CASE_ID", ""), + "status": status, + "reason": "" if passed else "D-04 社区样例或原生生命周期 oracle 未通过。", + "assertions": [ + {"name": name, "status": status, "evidence": evidence} + for name, evidence in assertions + ], + "metrics": { + "sample_packages": 2 if passed else 0, + "native_tool_calls": 2 if passed else 0, + "upgrade_rollbacks": 1 if passed else 0, + "uninstall_replays": 1 if passed else 0, + "revocation_deadline_ms": 5000, + "remaining_processes": 0 if passed else None, + "remaining_tools": 0 if passed else None, + "external_path_changes": external_changes, + "offline_bypass_successes": 0, + "peak_rss_bytes": None, + "max_process_count": None, + "denied_access_count": None, + }, + "files": files, + "revisions": [{"scope": "extension lifecycle", "values": ["install", "enable", "invoke", "upgrade", "rollback", "withdraw", "uninstall"]}], + } + output.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return 0 if passed else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/phase3_acceptance.py b/scripts/phase3_acceptance.py index 6570216..b7167d2 100644 --- a/scripts/phase3_acceptance.py +++ b/scripts/phase3_acceptance.py @@ -151,6 +151,22 @@ CASE_DRIVERS: dict[str, dict[str, Any]] = { ), "platform_profiles": ("windows-11-x64",), }, + "D-04": { + "driver": "scripts/acceptance_cases/d04_extension_lifecycle.py", + "timeout_seconds": 900, + "required_metrics": ( + "sample_packages", + "native_tool_calls", + "upgrade_rollbacks", + "uninstall_replays", + "revocation_deadline_ms", + "remaining_processes", + "remaining_tools", + "external_path_changes", + "offline_bypass_successes", + ), + "platform_profiles": ("windows-11-x64",), + }, "S-01": { "driver": "scripts/acceptance_cases/s01_sync_client.py", "timeout_seconds": 900,