#!/usr/bin/env python3 from __future__ import annotations import argparse import json import sys import urllib.request from urllib.error import HTTPError, URLError from pathlib import Path from typing import Any DEFAULT_BASE_URL = "http://docker-gpu.cin.su:8011" DEFAULT_MCP_URL = "http://docker.cin.su:8021" EXPECTED_CONTRACT_VERSION = "onec-selector-contract.v1" def rpc(base_url: str, method: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]: body = json.dumps({"method": method, "payload": payload}, ensure_ascii=False).encode("utf-8") request = urllib.request.Request( base_url.rstrip("/") + "/rpc", data=body, headers={"Content-Type": "application/json; charset=utf-8"}, method="POST", ) with urllib.request.urlopen(request, timeout=timeout) as response: return json.loads(response.read().decode("utf-8")) def post_json(url: str, payload: dict[str, Any], *, timeout: float, headers: dict[str, str] | None = None) -> tuple[dict[str, str], dict[str, Any]]: request = urllib.request.Request( url, data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), method="POST", headers={"Content-Type": "application/json", **(headers or {})}, ) with urllib.request.urlopen(request, timeout=timeout) as response: raw = response.read().decode("utf-8") data = json.loads(raw) if raw else {} return dict(response.headers), data if isinstance(data, dict) else {"status": "error", "error": "response_not_object", "response": data} def mcp_initialize(mcp_url: str, timeout: float) -> tuple[str | None, dict[str, Any]]: body = { "jsonrpc": "2.0", "id": "write-plan-safety-initialize", "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "smoke_1c_write_plan_safety", "version": "1"}, }, } try: headers, data = post_json( mcp_url.rstrip("/") + "/mcp", body, timeout=timeout, headers={"Accept": "application/json, text/event-stream"}, ) except HTTPError as exc: raw = exc.read().decode("utf-8", errors="replace") return None, {"status": "error", "error": "mcp_initialize_http_error", "http_status": exc.code, "response": raw} except URLError as exc: return None, {"status": "error", "error": "mcp_initialize_url_error", "message": str(exc)} if "error" in data: return None, {"status": "error", "error": "mcp_initialize_jsonrpc_error", "details": data.get("error")} return headers.get("Mcp-Session-Id"), data def mcp_rpc(mcp_url: str, method: str, payload: dict[str, Any], timeout: float, session_id: str | None) -> dict[str, Any]: headers = {"Accept": "application/json, text/event-stream"} if session_id: headers["Mcp-Session-Id"] = session_id body = { "jsonrpc": "2.0", "id": f"write-plan-safety-{method}", "method": "tools/call", "params": {"name": "onec_request", "arguments": {"method": method, "payload": payload}}, } try: _, data = post_json(mcp_url.rstrip("/") + "/mcp", body, timeout=timeout, headers=headers) except HTTPError as exc: raw = exc.read().decode("utf-8", errors="replace") return {"status": "error", "error": "mcp_http_error", "http_status": exc.code, "response": raw} except URLError as exc: return {"status": "error", "error": "mcp_url_error", "message": str(exc)} if "error" in data: return {"status": "error", "error": "mcp_jsonrpc_error", "details": data.get("error")} result = data.get("result") if isinstance(data.get("result"), dict) else {} content = result.get("content") if isinstance(result.get("content"), list) else [] first = content[0] if content and isinstance(content[0], dict) else {} text = first.get("text") if not isinstance(text, str): return {"status": "error", "error": "mcp_tool_text_missing", "result": result} try: decoded = json.loads(text) except json.JSONDecodeError as exc: return {"status": "error", "error": "mcp_tool_text_not_json", "message": str(exc), "text": text[:500]} return decoded if isinstance(decoded, dict) else {"status": "error", "error": "mcp_tool_payload_not_object", "payload": decoded} def rpc_call(endpoint_url: str, method: str, payload: dict[str, Any], timeout: float, *, transport: str, session_id: str | None) -> dict[str, Any]: if transport == "mcp": return mcp_rpc(endpoint_url, method, payload, timeout, session_id) return rpc(endpoint_url, method, payload, timeout) def problem_codes(result: dict[str, Any]) -> set[str]: return {str(problem.get("code") or "") for problem in result.get("problems") or [] if isinstance(problem, dict)} def require(condition: bool, message: str, failures: list[str]) -> None: if not condition: failures.append(message) def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str) -> dict[str, Any]: failures: list[str] = [] checks: dict[str, Any] = {} session_id: str | None = None initialize_result: dict[str, Any] | None = None if transport == "mcp": session_id, initialize_result = mcp_initialize(endpoint_url, timeout) contract_version = (((initialize_result.get("result") or {}).get("serverInfo") or {}).get("contract_version") if isinstance(initialize_result.get("result"), dict) else None) checks["mcp.initialize"] = { "status": "ok" if session_id and contract_version == EXPECTED_CONTRACT_VERSION else "error", "session": bool(session_id), "contract_version": contract_version, } require(bool(session_id), "MCP initialize must return Mcp-Session-Id", failures) require(contract_version == EXPECTED_CONTRACT_VERSION, "MCP initialize must report expected contract version", failures) missing_base_id = rpc_call( endpoint_url, "metadata.write.plan", { "target": {"kind": "module", "module_ref": "ConfigCASSave:placeholder.0#stream:0"}, "intent": {"operation": "replace", "old": "old", "new": "new"}, "timeout_seconds": int(timeout), }, timeout, transport=transport, session_id=session_id, ) checks["mcp.blocks_missing_base_id"] = { "schema": missing_base_id.get("schema"), "status": missing_base_id.get("status"), "reason": missing_base_id.get("reason"), "method": missing_base_id.get("method"), } require(missing_base_id.get("schema") == "adapter_1c_mcp_policy.v1", "MCP missing-base response must come from MCP policy", failures) require(missing_base_id.get("status") == "blocked", "MCP must block live methods without base_id", failures) require(missing_base_id.get("reason") == "base_id_required", "MCP missing-base block must use base_id_required reason", failures) diagnostic_fallback = rpc_call( endpoint_url, "storage.files.list", { "base_id": base_id, "table": "Config", "limit": 1, "timeout_seconds": int(timeout), }, timeout, transport=transport, session_id=session_id, ) checks["mcp.blocks_diagnostic_fallback"] = { "schema": diagnostic_fallback.get("schema"), "status": diagnostic_fallback.get("status"), "reason": diagnostic_fallback.get("reason"), "method": diagnostic_fallback.get("method"), } require(diagnostic_fallback.get("schema") == "adapter_1c_mcp_policy.v1", "MCP diagnostic fallback response must come from MCP policy", failures) require(diagnostic_fallback.get("status") == "blocked", "MCP must block low-level diagnostic fallback methods", failures) require(diagnostic_fallback.get("reason") == "diagnostic_method", "MCP diagnostic fallback block must use diagnostic_method reason", failures) blocked_effective_apply = rpc_call( endpoint_url, "metadata.write", { "base_id": base_id, "target": {"canonical_path": "Документ.АвансовыйОтчет.Форма.ФормаДокумента.КнопкаЗаписать"}, "mode": "apply", "edits": [{"property": "Заголовок", "value": "BLOCKED_SMOKE"}], "allow_sql_saved_state_apply": True, "timeout_seconds": int(timeout), }, timeout, transport=transport, session_id=session_id, ) apply_gate = None if blocked_effective_apply.get("schema") == "onec_repository_write_gate.v1": apply_gate = { "status": blocked_effective_apply.get("status"), "error": blocked_effective_apply.get("error"), } # Repository/support coordination is allowed to block apply before the # metadata route is evaluated. Repeat in non-mutating plan mode so the # smoke still verifies the effective-path routing contract. blocked_effective = rpc_call( endpoint_url, "metadata.write", { "base_id": base_id, "target": {"canonical_path": "Документ.АвансовыйОтчет.Форма.ФормаДокумента.КнопкаЗаписать"}, "mode": "plan", "edits": [{"property": "Заголовок", "value": "BLOCKED_SMOKE"}], "timeout_seconds": int(timeout), }, timeout, transport=transport, session_id=session_id, ) else: blocked_effective = blocked_effective_apply checks["blocked_effective_form_path"] = { "status": blocked_effective.get("status"), "error": blocked_effective.get("error"), "routed_method": blocked_effective.get("routed_method"), "target_kind": blocked_effective.get("target_kind"), **({"apply_gate": apply_gate} if apply_gate else {}), } require(blocked_effective.get("status") == "blocked", "effective canonical path write must be blocked", failures) require(blocked_effective.get("error") == "write_plan_required", "effective canonical path write must require write plan", failures) require(blocked_effective.get("routed_method") == "metadata.write.plan", "effective canonical path must route to metadata.write.plan", failures) blocked_control = rpc_call( endpoint_url, "metadata.write.plan", { "base_id": base_id, "target": {"kind": "module", "module_ref": "ConfigCASSave:placeholder.0#stream:0"}, "intent": {"operation": "replace_with_control", "old": "old", "new": "new"}, "timeout_seconds": int(timeout), }, timeout, transport=transport, session_id=session_id, ) blocked_codes = problem_codes(blocked_control) checks["replace_with_control_without_control_fragment"] = { "status": blocked_control.get("status"), "allowed": blocked_control.get("allowed"), "problem_codes": sorted(blocked_codes), } require(blocked_control.get("allowed") is False, "replace_with_control without control fragment must be blocked", failures) require("missing_control_fragment" in blocked_codes, "blocked replace_with_control must expose missing_control_fragment", failures) allowed_control = rpc_call( endpoint_url, "metadata.write.plan", { "base_id": base_id, "target": {"kind": "module", "module_ref": "ConfigCASSave:placeholder.0#stream:0"}, "intent": { "operation": "replace_with_control", "control_fragment": "old", "new": "new", }, "timeout_seconds": int(timeout), }, timeout, transport=transport, session_id=session_id, ) route = allowed_control.get("route") if isinstance(allowed_control.get("route"), dict) else {} checks["replace_with_control_with_control_fragment"] = { "status": allowed_control.get("status"), "allowed": allowed_control.get("allowed"), "apply_method": route.get("apply_method"), "target_kind": (allowed_control.get("target") or {}).get("target_kind") if isinstance(allowed_control.get("target"), dict) else None, } require(allowed_control.get("allowed") is True, "replace_with_control with control fragment must be allowed for concrete route planning", failures) require(route.get("apply_method") == "metadata.module.write_apply", "allowed module write plan must route to metadata.module.write_apply", failures) drift_control = rpc_call( endpoint_url, "metadata.write.plan", { "base_id": base_id, "target": {"kind": "module", "module_ref": "ConfigCASSave:placeholder.0#stream:0"}, "intent": { "operation": "replace_with_control", "control_fragment": "old", "new": "new", "current_text": "changed", }, "timeout_seconds": int(timeout), }, timeout, transport=transport, session_id=session_id, ) drift_codes = problem_codes(drift_control) checks["replace_with_control_drift"] = { "status": drift_control.get("status"), "allowed": drift_control.get("allowed"), "problem_codes": sorted(drift_codes), } require(drift_control.get("allowed") is False, "replace_with_control drift must be blocked when current_text is provided", failures) require("control_fragment_drift" in drift_codes, "replace_with_control drift must expose control_fragment_drift", failures) return { "schema": "onec_write_plan_safety_smoke.v1", "status": "ok" if not failures else "failed", "endpoint_url": endpoint_url, "transport": transport, "base_id": base_id, "checks": checks, "failures": failures, **({"mcp_initialize": initialize_result} if initialize_result and failures else {}), } def main() -> int: parser = argparse.ArgumentParser(description="Smoke deployed 1C write-plan safety behavior without requiring saved-state rows.") parser.add_argument("--base-url", default=DEFAULT_BASE_URL) parser.add_argument("--mcp-url", default=DEFAULT_MCP_URL) parser.add_argument("--transport", choices=("rest", "mcp"), default="rest") parser.add_argument("--base-id", default="upo_test") parser.add_argument("--timeout", type=float, default=30.0) parser.add_argument("--report", type=Path) args = parser.parse_args() endpoint_url = args.mcp_url if args.transport == "mcp" else args.base_url report = run_smoke(endpoint_url, args.base_id, args.timeout, transport=args.transport) if args.report: args.report.parent.mkdir(parents=True, exist_ok=True) args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print(json.dumps(report, ensure_ascii=False, indent=2)) return 0 if report["status"] == "ok" else 1 if __name__ == "__main__": raise SystemExit(main())