python-hwpx-automation 6.0.3__py3-none-any.whl
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.
- hwpx_automation/__init__.py +61 -0
- hwpx_automation/__init__.pyi +27 -0
- hwpx_automation/__main__.py +8 -0
- hwpx_automation/agent_document.py +392 -0
- hwpx_automation/api.py +136 -0
- hwpx_automation/blind_eval.py +407 -0
- hwpx_automation/capabilities.py +110 -0
- hwpx_automation/compat.py +48 -0
- hwpx_automation/configuration.py +60 -0
- hwpx_automation/core/__init__.py +2 -0
- hwpx_automation/core/content.py +762 -0
- hwpx_automation/core/context.py +111 -0
- hwpx_automation/core/diff.py +53 -0
- hwpx_automation/core/document.py +37 -0
- hwpx_automation/core/formatting.py +513 -0
- hwpx_automation/core/handles.py +24 -0
- hwpx_automation/core/locations.py +205 -0
- hwpx_automation/core/locator.py +162 -0
- hwpx_automation/core/plan.py +680 -0
- hwpx_automation/core/resources.py +42 -0
- hwpx_automation/core/search.py +296 -0
- hwpx_automation/core/transactions.py +434 -0
- hwpx_automation/core/txn.py +48 -0
- hwpx_automation/document_state.py +95 -0
- hwpx_automation/errors.py +174 -0
- hwpx_automation/execution_lock.py +15 -0
- hwpx_automation/fastmcp_adapter.py +672 -0
- hwpx_automation/form_fill.py +1177 -0
- hwpx_automation/form_output_models.py +223 -0
- hwpx_automation/handlers/__init__.py +2 -0
- hwpx_automation/handlers/_shared.py +377 -0
- hwpx_automation/handlers/agent_document.py +257 -0
- hwpx_automation/handlers/authoring.py +750 -0
- hwpx_automation/handlers/content_edit.py +1078 -0
- hwpx_automation/handlers/form_fill.py +607 -0
- hwpx_automation/handlers/layout_style.py +660 -0
- hwpx_automation/handlers/quality_render.py +566 -0
- hwpx_automation/handlers/read_export.py +1295 -0
- hwpx_automation/handlers/specialized.py +624 -0
- hwpx_automation/handlers/tracked_changes.py +589 -0
- hwpx_automation/handlers/workflow.py +105 -0
- hwpx_automation/hwp_converter.py +227 -0
- hwpx_automation/hwp_support.py +94 -0
- hwpx_automation/hwpx_ops.py +1439 -0
- hwpx_automation/identity.json +263 -0
- hwpx_automation/identity.py +18 -0
- hwpx_automation/ingest_adapters.py +85 -0
- hwpx_automation/markdown_plan.py +216 -0
- hwpx_automation/mcp_cli.py +29 -0
- hwpx_automation/metadata/tools_meta.py +40 -0
- hwpx_automation/mixed_form.py +3007 -0
- hwpx_automation/mutation_models.py +401 -0
- hwpx_automation/network_policy.py +232 -0
- hwpx_automation/office/__init__.py +14 -0
- hwpx_automation/office/agent/__init__.py +125 -0
- hwpx_automation/office/agent/_batch_verification.py +383 -0
- hwpx_automation/office/agent/blueprint/__init__.py +58 -0
- hwpx_automation/office/agent/blueprint/bundle.py +282 -0
- hwpx_automation/office/agent/blueprint/catalog.py +136 -0
- hwpx_automation/office/agent/blueprint/dump.py +520 -0
- hwpx_automation/office/agent/blueprint/mapping.py +312 -0
- hwpx_automation/office/agent/blueprint/model.py +722 -0
- hwpx_automation/office/agent/blueprint/native.py +621 -0
- hwpx_automation/office/agent/blueprint/replay.py +622 -0
- hwpx_automation/office/agent/catalog.py +252 -0
- hwpx_automation/office/agent/cli.py +647 -0
- hwpx_automation/office/agent/commands.py +1383 -0
- hwpx_automation/office/agent/document.py +801 -0
- hwpx_automation/office/agent/form_plan.py +1760 -0
- hwpx_automation/office/agent/model.py +808 -0
- hwpx_automation/office/agent/path.py +155 -0
- hwpx_automation/office/agent/query.py +230 -0
- hwpx_automation/office/agent/story.py +207 -0
- hwpx_automation/office/authoring/__init__.py +3542 -0
- hwpx_automation/office/authoring/advanced_generators.py +154 -0
- hwpx_automation/office/authoring/builder/__init__.py +52 -0
- hwpx_automation/office/authoring/builder/core.py +996 -0
- hwpx_automation/office/authoring/builder/report.py +195 -0
- hwpx_automation/office/authoring/design/__init__.py +30 -0
- hwpx_automation/office/authoring/design/_support.py +144 -0
- hwpx_automation/office/authoring/design/composer.py +282 -0
- hwpx_automation/office/authoring/design/harvest.py +305 -0
- hwpx_automation/office/authoring/design/plan.py +69 -0
- hwpx_automation/office/authoring/design/profile.py +88 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/validator.py +107 -0
- hwpx_automation/office/authoring/presets/__init__.py +22 -0
- hwpx_automation/office/authoring/presets/proposal.py +538 -0
- hwpx_automation/office/authoring/report_parser.py +141 -0
- hwpx_automation/office/authoring/style_profile.py +437 -0
- hwpx_automation/office/authoring/template_analyzer.py +657 -0
- hwpx_automation/office/compliance/__init__.py +38 -0
- hwpx_automation/office/compliance/official_lint.py +478 -0
- hwpx_automation/office/compliance/pii.py +388 -0
- hwpx_automation/office/document_ops/__init__.py +13 -0
- hwpx_automation/office/document_ops/comparison.py +62 -0
- hwpx_automation/office/document_ops/mail_merge.py +73 -0
- hwpx_automation/office/document_ops/redline.py +35 -0
- hwpx_automation/office/evalplan/__init__.py +36 -0
- hwpx_automation/office/evalplan/runtime.py +2762 -0
- hwpx_automation/office/exam/__init__.py +44 -0
- hwpx_automation/office/exam/compose.py +282 -0
- hwpx_automation/office/exam/ir.py +44 -0
- hwpx_automation/office/exam/measure.py +163 -0
- hwpx_automation/office/exam/parser.py +150 -0
- hwpx_automation/office/exam/profile.py +123 -0
- hwpx_automation/office/form_fill/__init__.py +66 -0
- hwpx_automation/office/form_fill/classification.py +108 -0
- hwpx_automation/office/form_fill/fill_residue.py +242 -0
- hwpx_automation/office/form_fill/fit/__init__.py +36 -0
- hwpx_automation/office/form_fill/fit/apply.py +24 -0
- hwpx_automation/office/form_fill/fit/engine.py +24 -0
- hwpx_automation/office/form_fill/fit/measure.py +50 -0
- hwpx_automation/office/form_fill/fit/policy.py +28 -0
- hwpx_automation/office/form_fill/fit/report.py +28 -0
- hwpx_automation/office/form_fill/fit/seal.py +457 -0
- hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
- hwpx_automation/office/form_fill/guidance.py +704 -0
- hwpx_automation/office/form_fill/quality.py +961 -0
- hwpx_automation/office/form_fill/split_run.py +333 -0
- hwpx_automation/office/form_fill/template_formfit.py +656 -0
- hwpx_automation/office/house_style/__init__.py +196 -0
- hwpx_automation/office/house_style/composition.py +68 -0
- hwpx_automation/office/house_style/data/bank.json +625 -0
- hwpx_automation/office/house_style/data/genres.json +43 -0
- hwpx_automation/office/quality/__init__.py +14 -0
- hwpx_automation/office/quality/page_guard.py +277 -0
- hwpx_automation/office/rendering/__init__.py +145 -0
- hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
- hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
- hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
- hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
- hwpx_automation/office/rendering/block_splits.py +76 -0
- hwpx_automation/office/rendering/detectors.py +151 -0
- hwpx_automation/office/rendering/diff.py +153 -0
- hwpx_automation/office/rendering/fixture_corpus.py +215 -0
- hwpx_automation/office/rendering/oracle.py +909 -0
- hwpx_automation/office/rendering/page_qa.py +245 -0
- hwpx_automation/office/rendering/qa_contracts.py +293 -0
- hwpx_automation/office/rendering/qa_metrics.py +241 -0
- hwpx_automation/office/rendering/worker.py +290 -0
- hwpx_automation/office/utilities/__init__.py +12 -0
- hwpx_automation/office/utilities/table_compute.py +477 -0
- hwpx_automation/ops_services/__init__.py +1 -0
- hwpx_automation/ops_services/_border_fill.py +283 -0
- hwpx_automation/ops_services/composition.py +55 -0
- hwpx_automation/ops_services/content_layout.py +322 -0
- hwpx_automation/ops_services/context.py +213 -0
- hwpx_automation/ops_services/form_fields.py +557 -0
- hwpx_automation/ops_services/media.py +178 -0
- hwpx_automation/ops_services/memo_style.py +477 -0
- hwpx_automation/ops_services/package_validation.py +166 -0
- hwpx_automation/ops_services/planning.py +201 -0
- hwpx_automation/ops_services/preview_export.py +585 -0
- hwpx_automation/ops_services/read_query.py +601 -0
- hwpx_automation/ops_services/save_policy.py +604 -0
- hwpx_automation/ops_services/tables.py +539 -0
- hwpx_automation/ops_services/transactions.py +616 -0
- hwpx_automation/preview_output_models.py +69 -0
- hwpx_automation/public-modules.json +206 -0
- hwpx_automation/py.typed +1 -0
- hwpx_automation/quality.py +351 -0
- hwpx_automation/quality_generation.py +725 -0
- hwpx_automation/runtime.py +321 -0
- hwpx_automation/runtime_services.py +100 -0
- hwpx_automation/server.py +259 -0
- hwpx_automation/storage.py +747 -0
- hwpx_automation/tool_bindings.py +170 -0
- hwpx_automation/tool_contract.py +982 -0
- hwpx_automation/upstream.py +755 -0
- hwpx_automation/utils/__init__.py +2 -0
- hwpx_automation/utils/helpers.py +29 -0
- hwpx_automation/visual_qa.py +667 -0
- hwpx_automation/workflow/__init__.py +55 -0
- hwpx_automation/workflow/adapters.py +482 -0
- hwpx_automation/workflow/dispatcher.py +213 -0
- hwpx_automation/workflow/models.py +243 -0
- hwpx_automation/workflow/policy.py +198 -0
- hwpx_automation/workflow/render_contracts.py +173 -0
- hwpx_automation/workflow/render_metrics.py +196 -0
- hwpx_automation/workflow/render_queue.py +482 -0
- hwpx_automation/workflow/render_security.py +172 -0
- hwpx_automation/workflow/render_transport.py +369 -0
- hwpx_automation/workflow/rendering.py +206 -0
- hwpx_automation/workflow/service.py +758 -0
- hwpx_automation/workflow/state_machine.py +65 -0
- hwpx_automation/workflow/store.py +747 -0
- hwpx_automation/workspace.py +1694 -0
- python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
- python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
- python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
- python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
- python_hwpx_automation-6.0.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Authenticated private HTTP transport for the durable render queue."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import hashlib
|
|
8
|
+
import hmac
|
|
9
|
+
import ipaddress
|
|
10
|
+
import json
|
|
11
|
+
import socket
|
|
12
|
+
import ssl
|
|
13
|
+
import time
|
|
14
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Literal
|
|
17
|
+
from urllib.error import HTTPError
|
|
18
|
+
from urllib.parse import quote, urlparse
|
|
19
|
+
from urllib.request import Request
|
|
20
|
+
|
|
21
|
+
from ..network_policy import NetworkPolicy, open_url
|
|
22
|
+
from .render_queue import DurableRenderQueue, RenderQueueError, sign_submission
|
|
23
|
+
from .rendering import RenderJobV2, RenderReceiptV2
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def request_signature(
|
|
27
|
+
secret: bytes, method: str, path: str, timestamp: str, body: bytes
|
|
28
|
+
) -> str:
|
|
29
|
+
digest = hashlib.sha256(body).hexdigest()
|
|
30
|
+
message = f"{method.upper()}\n{path}\n{timestamp}\n{digest}".encode()
|
|
31
|
+
return "sha256=" + hmac.new(secret, message, hashlib.sha256).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RemoteRenderClientV2:
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
base_url: str,
|
|
38
|
+
*,
|
|
39
|
+
secret: bytes,
|
|
40
|
+
transport_auth: Literal["mtls", "signed_https"] = "mtls",
|
|
41
|
+
ca_file: Path | None = None,
|
|
42
|
+
client_certfile: Path | None = None,
|
|
43
|
+
client_keyfile: Path | None = None,
|
|
44
|
+
timeout: float = 30,
|
|
45
|
+
allow_insecure_loopback: bool = False,
|
|
46
|
+
allow_private_network: bool = False,
|
|
47
|
+
) -> None:
|
|
48
|
+
parsed = urlparse(base_url)
|
|
49
|
+
if transport_auth not in {"mtls", "signed_https"}:
|
|
50
|
+
raise ValueError("transport_auth must be 'mtls' or 'signed_https'")
|
|
51
|
+
insecure_loopback = bool(
|
|
52
|
+
allow_insecure_loopback
|
|
53
|
+
and parsed.scheme == "http"
|
|
54
|
+
and parsed.hostname in {"127.0.0.1", "localhost", "::1"}
|
|
55
|
+
)
|
|
56
|
+
if parsed.scheme != "https" and not insecure_loopback:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
"render queue URL must use HTTPS (HTTP is test-only loopback)"
|
|
59
|
+
)
|
|
60
|
+
if transport_auth == "mtls" and not insecure_loopback:
|
|
61
|
+
if not ca_file or not client_certfile or not client_keyfile:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
"mTLS requires a CA trust root, client certificate, and client key"
|
|
64
|
+
)
|
|
65
|
+
context = ssl.create_default_context(
|
|
66
|
+
ssl.Purpose.SERVER_AUTH, cafile=str(ca_file)
|
|
67
|
+
)
|
|
68
|
+
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
69
|
+
context.load_cert_chain(str(client_certfile), str(client_keyfile))
|
|
70
|
+
elif transport_auth == "signed_https":
|
|
71
|
+
if client_certfile or client_keyfile:
|
|
72
|
+
raise ValueError("signed_https does not accept mTLS client credentials")
|
|
73
|
+
context = ssl.create_default_context(
|
|
74
|
+
ssl.Purpose.SERVER_AUTH, cafile=str(ca_file) if ca_file else None
|
|
75
|
+
)
|
|
76
|
+
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
77
|
+
else: # explicit test-only HTTP loopback in mTLS mode
|
|
78
|
+
context = None
|
|
79
|
+
self.base_url = base_url.rstrip("/")
|
|
80
|
+
self.secret = secret
|
|
81
|
+
self.transport_auth = transport_auth
|
|
82
|
+
self._ssl_context = context
|
|
83
|
+
self.timeout = timeout
|
|
84
|
+
self._network_policy = NetworkPolicy(
|
|
85
|
+
allow_private_network=allow_private_network or allow_insecure_loopback,
|
|
86
|
+
allow_http=insecure_loopback,
|
|
87
|
+
)
|
|
88
|
+
self._network_policy.validate_url(self.base_url)
|
|
89
|
+
|
|
90
|
+
def _request(
|
|
91
|
+
self,
|
|
92
|
+
method: str,
|
|
93
|
+
path: str,
|
|
94
|
+
payload: dict[str, Any] | None = None,
|
|
95
|
+
*,
|
|
96
|
+
raw: bool = False,
|
|
97
|
+
):
|
|
98
|
+
body = (
|
|
99
|
+
json.dumps(payload, separators=(",", ":")).encode()
|
|
100
|
+
if payload is not None
|
|
101
|
+
else b""
|
|
102
|
+
)
|
|
103
|
+
timestamp = str(int(time.time()))
|
|
104
|
+
request = Request(
|
|
105
|
+
self.base_url + path,
|
|
106
|
+
data=body if method != "GET" else None,
|
|
107
|
+
method=method,
|
|
108
|
+
headers={
|
|
109
|
+
"Content-Type": "application/json",
|
|
110
|
+
"X-HWPX-Timestamp": timestamp,
|
|
111
|
+
"X-HWPX-Signature": request_signature(
|
|
112
|
+
self.secret, method, path, timestamp, body
|
|
113
|
+
),
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
try:
|
|
117
|
+
with open_url(
|
|
118
|
+
request,
|
|
119
|
+
policy=self._network_policy,
|
|
120
|
+
timeout=self.timeout,
|
|
121
|
+
context=self._ssl_context,
|
|
122
|
+
) as response:
|
|
123
|
+
data = response.read()
|
|
124
|
+
except HTTPError as exc:
|
|
125
|
+
raise RenderQueueError(
|
|
126
|
+
f"REMOTE_HTTP_{exc.code}", "remote render queue request failed"
|
|
127
|
+
) from exc
|
|
128
|
+
return data if raw else json.loads(data)
|
|
129
|
+
|
|
130
|
+
def capabilities(self) -> dict[str, object]:
|
|
131
|
+
return self._request("GET", "/v1/health")
|
|
132
|
+
|
|
133
|
+
def submit(self, job: RenderJobV2, source_path: Path) -> RenderReceiptV2:
|
|
134
|
+
response = self._request(
|
|
135
|
+
"POST",
|
|
136
|
+
"/v1/jobs",
|
|
137
|
+
{
|
|
138
|
+
"job": job.model_dump(mode="json"),
|
|
139
|
+
"filename": source_path.name,
|
|
140
|
+
"contentBase64": base64.b64encode(source_path.read_bytes()).decode(
|
|
141
|
+
"ascii"
|
|
142
|
+
),
|
|
143
|
+
},
|
|
144
|
+
)
|
|
145
|
+
return RenderReceiptV2.model_validate(response["receipt"])
|
|
146
|
+
|
|
147
|
+
def get(self, job_id: str) -> RenderReceiptV2:
|
|
148
|
+
response = self._request("GET", f"/v1/jobs/{quote(job_id, safe='')}")
|
|
149
|
+
return RenderReceiptV2.model_validate(response["receipt"])
|
|
150
|
+
|
|
151
|
+
def cancel(self, job_id: str) -> RenderReceiptV2:
|
|
152
|
+
response = self._request(
|
|
153
|
+
"POST", f"/v1/jobs/{quote(job_id, safe='')}/cancel", {}
|
|
154
|
+
)
|
|
155
|
+
return RenderReceiptV2.model_validate(response["receipt"])
|
|
156
|
+
|
|
157
|
+
def fetch_artifact(self, job_id: str, content_hash: str) -> bytes:
|
|
158
|
+
digest = content_hash.removeprefix("sha256:")
|
|
159
|
+
data = self._request(
|
|
160
|
+
"GET", f"/v1/jobs/{quote(job_id, safe='')}/artifacts/{digest}", raw=True
|
|
161
|
+
)
|
|
162
|
+
if "sha256:" + hashlib.sha256(data).hexdigest() != content_hash:
|
|
163
|
+
raise RenderQueueError(
|
|
164
|
+
"ARTIFACT_HASH_MISMATCH", "downloaded artifact hash mismatch"
|
|
165
|
+
)
|
|
166
|
+
return data
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def make_queue_handler(
|
|
170
|
+
queue: DurableRenderQueue, *, secret: bytes, max_clock_skew_seconds: int = 300
|
|
171
|
+
):
|
|
172
|
+
class QueueHandler(BaseHTTPRequestHandler):
|
|
173
|
+
server_version = "HWPXRenderQueue/1"
|
|
174
|
+
|
|
175
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
176
|
+
return None # never log paths, query strings, headers, or document metadata
|
|
177
|
+
|
|
178
|
+
def _body(self) -> bytes:
|
|
179
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
180
|
+
if length > queue.policy.max_input_bytes * 2:
|
|
181
|
+
raise RenderQueueError(
|
|
182
|
+
"REQUEST_TOO_LARGE", "remote request body exceeds limit"
|
|
183
|
+
)
|
|
184
|
+
return self.rfile.read(length)
|
|
185
|
+
|
|
186
|
+
def _authenticate(self, body: bytes) -> None:
|
|
187
|
+
timestamp = self.headers.get("X-HWPX-Timestamp", "")
|
|
188
|
+
signature = self.headers.get("X-HWPX-Signature", "")
|
|
189
|
+
try:
|
|
190
|
+
fresh = abs(time.time() - int(timestamp)) <= max_clock_skew_seconds
|
|
191
|
+
except ValueError:
|
|
192
|
+
fresh = False
|
|
193
|
+
expected = request_signature(
|
|
194
|
+
secret, self.command, self.path, timestamp, body
|
|
195
|
+
)
|
|
196
|
+
if not fresh or not hmac.compare_digest(expected, signature):
|
|
197
|
+
raise RenderQueueError(
|
|
198
|
+
"REMOTE_AUTH_REJECTED", "remote request authentication rejected"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def _json(self, status: int, value: dict[str, Any]) -> None:
|
|
202
|
+
data = json.dumps(value, ensure_ascii=False).encode()
|
|
203
|
+
self.send_response(status)
|
|
204
|
+
self.send_header("Content-Type", "application/json")
|
|
205
|
+
self.send_header("Content-Length", str(len(data)))
|
|
206
|
+
self.end_headers()
|
|
207
|
+
self.wfile.write(data)
|
|
208
|
+
|
|
209
|
+
def _dispatch(self) -> None:
|
|
210
|
+
body = self._body()
|
|
211
|
+
self._authenticate(body)
|
|
212
|
+
parts = [part for part in self.path.split("/") if part]
|
|
213
|
+
if self.command == "GET" and self.path == "/v1/health":
|
|
214
|
+
self._json(200, queue.health())
|
|
215
|
+
return
|
|
216
|
+
if self.command == "POST" and self.path == "/v1/jobs":
|
|
217
|
+
payload = json.loads(body)
|
|
218
|
+
job = RenderJobV2.model_validate(payload["job"])
|
|
219
|
+
content = base64.b64decode(payload["contentBase64"], validate=True)
|
|
220
|
+
receipt = queue.submit(
|
|
221
|
+
job,
|
|
222
|
+
content,
|
|
223
|
+
signature=sign_submission(secret, job),
|
|
224
|
+
principal_id="remote-mcp-client",
|
|
225
|
+
filename=payload["filename"],
|
|
226
|
+
)
|
|
227
|
+
self._json(200, {"receipt": receipt.model_dump(mode="json")})
|
|
228
|
+
return
|
|
229
|
+
if (
|
|
230
|
+
len(parts) == 3
|
|
231
|
+
and parts[:2] == ["v1", "jobs"]
|
|
232
|
+
and self.command == "GET"
|
|
233
|
+
):
|
|
234
|
+
self._json(
|
|
235
|
+
200, {"receipt": queue.get(parts[2]).model_dump(mode="json")}
|
|
236
|
+
)
|
|
237
|
+
return
|
|
238
|
+
if (
|
|
239
|
+
len(parts) == 4
|
|
240
|
+
and parts[:2] == ["v1", "jobs"]
|
|
241
|
+
and parts[3] == "cancel"
|
|
242
|
+
and self.command == "POST"
|
|
243
|
+
):
|
|
244
|
+
self._json(
|
|
245
|
+
200, {"receipt": queue.cancel(parts[2]).model_dump(mode="json")}
|
|
246
|
+
)
|
|
247
|
+
return
|
|
248
|
+
if (
|
|
249
|
+
len(parts) == 5
|
|
250
|
+
and parts[:2] == ["v1", "jobs"]
|
|
251
|
+
and parts[3] == "artifacts"
|
|
252
|
+
and self.command == "GET"
|
|
253
|
+
):
|
|
254
|
+
receipt = queue.get(parts[2])
|
|
255
|
+
content_hash = "sha256:" + parts[4]
|
|
256
|
+
if content_hash not in {
|
|
257
|
+
item.content_hash for item in receipt.artifacts
|
|
258
|
+
}:
|
|
259
|
+
raise RenderQueueError(
|
|
260
|
+
"ARTIFACT_NOT_IN_RECEIPT",
|
|
261
|
+
"artifact is not bound to job receipt",
|
|
262
|
+
)
|
|
263
|
+
data = queue.content.path_for(content_hash).read_bytes()
|
|
264
|
+
self.send_response(200)
|
|
265
|
+
self.send_header("Content-Type", "application/octet-stream")
|
|
266
|
+
self.send_header("Content-Length", str(len(data)))
|
|
267
|
+
self.end_headers()
|
|
268
|
+
self.wfile.write(data)
|
|
269
|
+
return
|
|
270
|
+
self._json(404, {"errorCode": "NOT_FOUND"})
|
|
271
|
+
|
|
272
|
+
def do_GET(self) -> None:
|
|
273
|
+
self._safe_dispatch()
|
|
274
|
+
|
|
275
|
+
def do_POST(self) -> None:
|
|
276
|
+
self._safe_dispatch()
|
|
277
|
+
|
|
278
|
+
def _safe_dispatch(self) -> None:
|
|
279
|
+
try:
|
|
280
|
+
self._dispatch()
|
|
281
|
+
except (
|
|
282
|
+
RenderQueueError,
|
|
283
|
+
KeyError,
|
|
284
|
+
ValueError,
|
|
285
|
+
json.JSONDecodeError,
|
|
286
|
+
) as exc:
|
|
287
|
+
code = getattr(exc, "code", "BAD_REQUEST")
|
|
288
|
+
self._json(
|
|
289
|
+
401 if code == "REMOTE_AUTH_REJECTED" else 400, {"errorCode": code}
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
return QueueHandler
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def serve_private_queue(
|
|
296
|
+
queue: DurableRenderQueue,
|
|
297
|
+
*,
|
|
298
|
+
secret: bytes,
|
|
299
|
+
host: str,
|
|
300
|
+
port: int,
|
|
301
|
+
certfile: Path | None = None,
|
|
302
|
+
keyfile: Path | None = None,
|
|
303
|
+
client_ca_file: Path | None = None,
|
|
304
|
+
allow_insecure_loopback: bool = False,
|
|
305
|
+
allow_non_private_test_bind: bool = False,
|
|
306
|
+
) -> ThreadingHTTPServer:
|
|
307
|
+
if (
|
|
308
|
+
queue.policy.private_network_required
|
|
309
|
+
and not allow_non_private_test_bind
|
|
310
|
+
and not _is_private_bind_host(host)
|
|
311
|
+
):
|
|
312
|
+
raise ValueError("render queue must bind only to a private or loopback address")
|
|
313
|
+
insecure_loopback = bool(
|
|
314
|
+
allow_insecure_loopback and host in {"127.0.0.1", "localhost", "::1"}
|
|
315
|
+
)
|
|
316
|
+
if not certfile or not keyfile:
|
|
317
|
+
if not insecure_loopback:
|
|
318
|
+
raise ValueError(
|
|
319
|
+
"TLS certificate and key are required outside test-only loopback"
|
|
320
|
+
)
|
|
321
|
+
if (
|
|
322
|
+
queue.policy.transport_auth == "mtls"
|
|
323
|
+
and not insecure_loopback
|
|
324
|
+
and not client_ca_file
|
|
325
|
+
):
|
|
326
|
+
raise ValueError(
|
|
327
|
+
"mTLS requires a CA trust root for client-certificate verification"
|
|
328
|
+
)
|
|
329
|
+
if queue.policy.transport_auth == "signed_https" and client_ca_file:
|
|
330
|
+
raise ValueError("signed_https must not be configured as implicit mTLS")
|
|
331
|
+
server = ThreadingHTTPServer((host, port), make_queue_handler(queue, secret=secret))
|
|
332
|
+
if certfile and keyfile:
|
|
333
|
+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
334
|
+
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
335
|
+
context.load_cert_chain(str(certfile), str(keyfile))
|
|
336
|
+
if queue.policy.transport_auth == "mtls":
|
|
337
|
+
context.verify_mode = ssl.CERT_REQUIRED
|
|
338
|
+
context.load_verify_locations(cafile=str(client_ca_file))
|
|
339
|
+
server.socket = context.wrap_socket(server.socket, server_side=True)
|
|
340
|
+
return server
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _is_private_bind_host(host: str) -> bool:
|
|
344
|
+
"""Fail closed unless every address represented by *host* is private/loopback."""
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
addresses = {ipaddress.ip_address(host)}
|
|
348
|
+
except ValueError:
|
|
349
|
+
try:
|
|
350
|
+
addresses = {
|
|
351
|
+
ipaddress.ip_address(item[4][0].split("%", 1)[0])
|
|
352
|
+
for item in socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
|
|
353
|
+
}
|
|
354
|
+
except (OSError, ValueError):
|
|
355
|
+
return False
|
|
356
|
+
return bool(addresses) and all(
|
|
357
|
+
(address.is_private or address.is_loopback)
|
|
358
|
+
and not address.is_unspecified
|
|
359
|
+
and not address.is_multicast
|
|
360
|
+
for address in addresses
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
__all__ = [
|
|
365
|
+
"RemoteRenderClientV2",
|
|
366
|
+
"make_queue_handler",
|
|
367
|
+
"request_signature",
|
|
368
|
+
"serve_private_queue",
|
|
369
|
+
]
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Transport-neutral render boundary consumed by the workflow engine.
|
|
3
|
+
|
|
4
|
+
The base workflow never claims that this interface rendered a document. A later
|
|
5
|
+
real-Hancom backend and provenance receipts behind this frozen contract.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
14
|
+
|
|
15
|
+
# Shared v2 contracts live in the leaf module that removed the import cycle; they are
|
|
16
|
+
# re-exported here so existing importers keep working unchanged.
|
|
17
|
+
from .render_contracts import ( # noqa: F401 - compatibility re-exports
|
|
18
|
+
RENDER_SCHEMA_VERSION_V2,
|
|
19
|
+
SHA256_PATTERN,
|
|
20
|
+
RenderArtifactKind,
|
|
21
|
+
RenderArtifactV2,
|
|
22
|
+
RenderJobV2,
|
|
23
|
+
RenderReceiptV2,
|
|
24
|
+
RenderStatus,
|
|
25
|
+
sign_submission,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
RENDER_SCHEMA_VERSION = "hwpx.render.v1"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RenderJob(BaseModel):
|
|
33
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
34
|
+
|
|
35
|
+
schema_version: str = RENDER_SCHEMA_VERSION
|
|
36
|
+
job_id: str = Field(min_length=8, max_length=200)
|
|
37
|
+
workflow_id: str = Field(min_length=8, max_length=200)
|
|
38
|
+
source_path: str
|
|
39
|
+
source_content_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
|
|
40
|
+
requested_backend: str = "real_hancom"
|
|
41
|
+
dpi: int = Field(default=144, ge=72, le=600)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RenderReceipt(BaseModel):
|
|
45
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
46
|
+
|
|
47
|
+
schema_version: str = RENDER_SCHEMA_VERSION
|
|
48
|
+
job_id: str
|
|
49
|
+
status: RenderStatus
|
|
50
|
+
backend: str | None = None
|
|
51
|
+
hancom_build: str | None = None
|
|
52
|
+
page_artifacts: tuple[str, ...] = ()
|
|
53
|
+
output_content_hash: str | None = None
|
|
54
|
+
render_checked: bool = False
|
|
55
|
+
error_code: str | None = None
|
|
56
|
+
|
|
57
|
+
@model_validator(mode="after")
|
|
58
|
+
def truthful_render_claim(self) -> "RenderReceipt":
|
|
59
|
+
if self.render_checked and (
|
|
60
|
+
self.status != RenderStatus.SUCCEEDED
|
|
61
|
+
or not self.backend
|
|
62
|
+
or not self.hancom_build
|
|
63
|
+
or not self.page_artifacts
|
|
64
|
+
):
|
|
65
|
+
raise ValueError(
|
|
66
|
+
"render_checked requires succeeded status, backend, Hancom build, and page artifacts"
|
|
67
|
+
)
|
|
68
|
+
if self.status == RenderStatus.SUCCEEDED and not self.output_content_hash:
|
|
69
|
+
raise ValueError("successful render receipt requires output_content_hash")
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@runtime_checkable
|
|
74
|
+
class RenderBackend(Protocol):
|
|
75
|
+
"""Stable provider boundary; implementations may be local or remote."""
|
|
76
|
+
|
|
77
|
+
def capabilities(self) -> dict[str, object]: ...
|
|
78
|
+
|
|
79
|
+
def submit(self, job: RenderJob) -> RenderReceipt: ...
|
|
80
|
+
|
|
81
|
+
def get(self, job_id: str) -> RenderReceipt: ...
|
|
82
|
+
|
|
83
|
+
def cancel(self, job_id: str) -> RenderReceipt: ...
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@runtime_checkable
|
|
87
|
+
class RenderClientV2(Protocol):
|
|
88
|
+
def capabilities(self) -> dict[str, object]: ...
|
|
89
|
+
def submit(self, job: RenderJobV2, source_path: Path) -> RenderReceiptV2: ...
|
|
90
|
+
def get(self, job_id: str) -> RenderReceiptV2: ...
|
|
91
|
+
def cancel(self, job_id: str) -> RenderReceiptV2: ...
|
|
92
|
+
def fetch_artifact(self, job_id: str, content_hash: str) -> bytes: ...
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class NullRenderClientV2:
|
|
96
|
+
def capabilities(self) -> dict[str, object]:
|
|
97
|
+
return {
|
|
98
|
+
"schemaVersion": "hwpx.render-health.v1", "available": False,
|
|
99
|
+
"degraded": True, "degradedReason": "NOT_CONFIGURED", "queueDepth": 0,
|
|
100
|
+
"runningJobs": 0, "oldestQueuedAgeSeconds": 0.0,
|
|
101
|
+
"lastSuccessfulRealRender": None, "workerVersion": None, "hancomBuild": None,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
def submit(self, job: RenderJobV2, source_path: Path) -> RenderReceiptV2:
|
|
105
|
+
del source_path
|
|
106
|
+
return RenderReceiptV2(
|
|
107
|
+
job_id=job.job_id, workflow_id=job.workflow_id,
|
|
108
|
+
input_content_hash=job.source_content_hash, status=RenderStatus.UNAVAILABLE,
|
|
109
|
+
queued_at=job.submitted_at, completed_at=job.submitted_at,
|
|
110
|
+
terminal_reason="REAL_HANCOM_BACKEND_UNAVAILABLE",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def get(self, job_id: str) -> RenderReceiptV2:
|
|
114
|
+
raise KeyError(job_id)
|
|
115
|
+
|
|
116
|
+
def cancel(self, job_id: str) -> RenderReceiptV2:
|
|
117
|
+
raise KeyError(job_id)
|
|
118
|
+
|
|
119
|
+
def fetch_artifact(self, job_id: str, content_hash: str) -> bytes:
|
|
120
|
+
raise KeyError(job_id)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class QueueRenderClientV2:
|
|
124
|
+
"""Trusted local client for the authenticated private durable queue."""
|
|
125
|
+
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
queue: object,
|
|
129
|
+
*,
|
|
130
|
+
secret: bytes,
|
|
131
|
+
# Compatibility-preserved through 6.x: this value participates in
|
|
132
|
+
# signed queue authorization, so renaming it would strand queued jobs.
|
|
133
|
+
principal_id: str = "hwpx-mcp-server",
|
|
134
|
+
) -> None:
|
|
135
|
+
self.queue = queue
|
|
136
|
+
self.secret = secret
|
|
137
|
+
self.principal_id = principal_id
|
|
138
|
+
|
|
139
|
+
def capabilities(self) -> dict[str, object]:
|
|
140
|
+
return self.queue.health()
|
|
141
|
+
|
|
142
|
+
def submit(self, job: RenderJobV2, source_path: Path) -> RenderReceiptV2:
|
|
143
|
+
return self.queue.submit(
|
|
144
|
+
job, source_path.read_bytes(), signature=sign_submission(self.secret, job),
|
|
145
|
+
principal_id=self.principal_id, filename=source_path.name,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def get(self, job_id: str) -> RenderReceiptV2:
|
|
149
|
+
return self.queue.get(job_id)
|
|
150
|
+
|
|
151
|
+
def cancel(self, job_id: str) -> RenderReceiptV2:
|
|
152
|
+
return self.queue.cancel(job_id)
|
|
153
|
+
|
|
154
|
+
def fetch_artifact(self, job_id: str, content_hash: str) -> bytes:
|
|
155
|
+
receipt = self.queue.get(job_id)
|
|
156
|
+
if content_hash not in {item.content_hash for item in receipt.artifacts}:
|
|
157
|
+
raise KeyError(content_hash)
|
|
158
|
+
return self.queue.content.path_for(content_hash).read_bytes()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class NullRenderBackend:
|
|
162
|
+
"""Honest default: render evidence is unavailable, never silently true."""
|
|
163
|
+
|
|
164
|
+
def capabilities(self) -> dict[str, object]:
|
|
165
|
+
return {
|
|
166
|
+
"schemaVersion": RENDER_SCHEMA_VERSION,
|
|
167
|
+
"available": False,
|
|
168
|
+
"backend": "none",
|
|
169
|
+
"renderChecked": False,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
def submit(self, job: RenderJob) -> RenderReceipt:
|
|
173
|
+
return self._unavailable(job.job_id)
|
|
174
|
+
|
|
175
|
+
def get(self, job_id: str) -> RenderReceipt:
|
|
176
|
+
return self._unavailable(job_id)
|
|
177
|
+
|
|
178
|
+
def cancel(self, job_id: str) -> RenderReceipt:
|
|
179
|
+
return RenderReceipt(job_id=job_id, status=RenderStatus.CANCELLED, error_code="NO_RENDER_BACKEND")
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def _unavailable(job_id: str) -> RenderReceipt:
|
|
183
|
+
return RenderReceipt(
|
|
184
|
+
job_id=job_id,
|
|
185
|
+
status=RenderStatus.UNAVAILABLE,
|
|
186
|
+
render_checked=False,
|
|
187
|
+
error_code="REAL_HANCOM_BACKEND_UNAVAILABLE",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
__all__ = [
|
|
192
|
+
"RENDER_SCHEMA_VERSION",
|
|
193
|
+
"RENDER_SCHEMA_VERSION_V2",
|
|
194
|
+
"NullRenderBackend",
|
|
195
|
+
"NullRenderClientV2",
|
|
196
|
+
"QueueRenderClientV2",
|
|
197
|
+
"RenderArtifactKind",
|
|
198
|
+
"RenderArtifactV2",
|
|
199
|
+
"RenderBackend",
|
|
200
|
+
"RenderClientV2",
|
|
201
|
+
"RenderJob",
|
|
202
|
+
"RenderJobV2",
|
|
203
|
+
"RenderReceipt",
|
|
204
|
+
"RenderReceiptV2",
|
|
205
|
+
"RenderStatus",
|
|
206
|
+
]
|