hooks.py python
363 lines 12.7 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 days ago
1 """Knowtation pre-merge hooks — Phase 4.4.
2
3 Implements :class:`muse.core.merge_hooks.MergeHookPlugin` for the
4 Knowtation domain. Today the only hook is the consolidation kick: when
5 the user passes ``muse merge --consolidate``, the hook fires a
6 fire-and-forget HTTP ``POST /api/v1/memory/consolidate`` against the
7 local Knowtation hub (``hub/server.mjs``) and returns immediately. The
8 merge engine never waits on the response — per Q3 of the Knowtation
9 domain plan, consolidation runs *external process today, declarative
10 pre-merge hook later*, so the merge UX is never blocked on LLM latency.
11
12 Security
13 --------
14
15 The HTTP target URL is built **server-side only** from a port number
16 that has been validated as an integer in the IANA-allowed range
17 (``1`` – ``65535``). The port is read from the environment variable
18 ``KNOWTATION_HUB_PORT`` (default ``3000``). No segment of the URL is
19 ever interpolated from user-controlled string input; an attacker
20 controlling the environment variable can at worst cause the hook to
21 emit an error result (``KNOWTATION_HUB_PORT=oops``) or reach a different
22 TCP port on ``localhost`` (``KNOWTATION_HUB_PORT=8080``). They cannot
23 inject shell metacharacters, redirect to a remote host, smuggle
24 additional URL paths, or escalate to anything beyond a single ``POST``
25 request to ``http://localhost:<port>/api/v1/memory/consolidate`` with
26 an empty JSON body.
27
28 Errors
29 ------
30
31 Any exception raised by :func:`urllib.request.urlopen` (connection
32 refused, timeout, DNS error, malformed response) is caught inside the
33 background thread, logged at ``WARNING`` level, and never propagates to
34 the merge process. The synchronous :class:`MergeHookResult` returned
35 by :meth:`KnowtationMergeHook.pre_merge_hook` always carries
36 ``out_of_band=True`` when the hook successfully *dispatched* the work,
37 even though the underlying HTTP call may still fail asynchronously.
38 """
39
40 from __future__ import annotations
41
42 import json
43 import logging
44 import os
45 import pathlib
46 import threading
47 import urllib.error
48 import urllib.request
49
50 from muse.core.merge_hooks import (
51 MergeHookPlugin,
52 MergeHookResult,
53 get_hook_registry,
54 )
55
56 logger = logging.getLogger(__name__)
57
58
59 # ---------------------------------------------------------------------------
60 # Configuration constants
61 # ---------------------------------------------------------------------------
62
63 #: Stable identifier for the consolidation hook. Used by both the
64 #: registry log lines and the ``muse merge --consolidate`` JSON output.
65 HOOK_NAME: str = "knowtation-consolidate"
66
67 #: Environment variable read for the Knowtation hub TCP port. Default
68 #: ``3000`` matches the canonical hub port used by Phase 4.4 fixtures
69 #: and the ``hub/server.mjs`` default.
70 ENV_PORT: str = "KNOWTATION_HUB_PORT"
71
72 #: Default port when ``KNOWTATION_HUB_PORT`` is unset or empty.
73 DEFAULT_PORT: int = 3000
74
75 #: Lowest legal TCP port number (excluding port ``0``, which is
76 #: reserved by IANA for "any free port" and is not a real listener).
77 PORT_MIN: int = 1
78
79 #: Highest legal TCP port number per RFC 6335.
80 PORT_MAX: int = 65535
81
82 #: Hard timeout (seconds) on the background HTTP call. The merge has
83 #: already returned by the time this fires; the ceiling exists only to
84 #: ensure the daemon thread does not leak resources for a hung server.
85 HTTP_TIMEOUT_SECONDS: float = 30.0
86
87 #: API path on the hub. Constructed as a constant string so callers
88 #: cannot influence it.
89 API_PATH: str = "/api/v1/memory/consolidate"
90
91
92 # ---------------------------------------------------------------------------
93 # Port validation
94 # ---------------------------------------------------------------------------
95
96
97 def _resolve_port() -> tuple[int | None, str | None]:
98 """Read and validate the configured Knowtation hub port.
99
100 Reads :data:`ENV_PORT` from :data:`os.environ`. When the variable
101 is missing or empty, returns :data:`DEFAULT_PORT`. Otherwise, the
102 value must parse as a base-10 integer in
103 ``[PORT_MIN, PORT_MAX]``. Any other input — including strings with
104 shell metacharacters such as ``"3000; rm -rf /"`` — fails the
105 integer parse and returns an error tuple.
106
107 Returns:
108 ``(port, None)`` on success, where *port* is an ``int`` in the
109 valid TCP range. ``(None, error_message)`` on failure, where
110 *error_message* is suitable for inclusion in a
111 :class:`MergeHookResult.error`.
112 """
113 raw = os.environ.get(ENV_PORT, "")
114 if not raw or not raw.strip():
115 return DEFAULT_PORT, None
116 raw = raw.strip()
117 try:
118 port = int(raw, 10)
119 except ValueError:
120 return None, (
121 f"{ENV_PORT}={raw!r} is not a valid integer "
122 f"(expected an integer in [{PORT_MIN}, {PORT_MAX}])"
123 )
124 if port < PORT_MIN or port > PORT_MAX:
125 return None, (
126 f"{ENV_PORT}={port} is out of range "
127 f"(must be in [{PORT_MIN}, {PORT_MAX}])"
128 )
129 return port, None
130
131
132 # ---------------------------------------------------------------------------
133 # Background HTTP worker
134 # ---------------------------------------------------------------------------
135
136
137 def _post_consolidate_in_background(url: str) -> threading.Thread:
138 """Spawn a daemon thread that POSTs ``{}`` to *url* and returns it.
139
140 The thread is started before the function returns. The thread is
141 marked as a daemon so it does not block process exit; the merge
142 command can return immediately while this thread is still running.
143
144 Args:
145 url: Pre-validated ``http://localhost:<port>/api/v1/memory/consolidate``
146 URL. Callers MUST construct the URL via :func:`_resolve_port`;
147 this function performs no further validation.
148
149 Returns:
150 The started :class:`threading.Thread`. Returned for testability
151 — the merge engine itself ignores it.
152 """
153
154 def _worker() -> None:
155 body = b"{}"
156 request = urllib.request.Request(
157 url=url,
158 data=body,
159 method="POST",
160 headers={
161 "Content-Type": "application/json",
162 "User-Agent": "muse-merge-hook/1.0",
163 "Content-Length": str(len(body)),
164 },
165 )
166 try:
167 with urllib.request.urlopen( # noqa: S310 — URL is server-built
168 request,
169 timeout=HTTP_TIMEOUT_SECONDS,
170 ) as response:
171 logger.debug(
172 "knowtation-consolidate: POST %s -> %s",
173 url,
174 getattr(response, "status", "unknown"),
175 )
176 except urllib.error.URLError as exc:
177 logger.warning(
178 "knowtation-consolidate: POST %s failed (URLError): %s",
179 url,
180 exc,
181 )
182 except TimeoutError as exc:
183 logger.warning(
184 "knowtation-consolidate: POST %s timed out after %.1fs: %s",
185 url,
186 HTTP_TIMEOUT_SECONDS,
187 exc,
188 )
189 except Exception as exc: # noqa: BLE001 — must not propagate
190 logger.warning(
191 "knowtation-consolidate: POST %s raised %s: %s",
192 url,
193 type(exc).__name__,
194 exc,
195 )
196
197 thread = threading.Thread(
198 target=_worker,
199 name="knowtation-consolidate-hook",
200 daemon=True,
201 )
202 thread.start()
203 return thread
204
205
206 # ---------------------------------------------------------------------------
207 # Public hook plugin
208 # ---------------------------------------------------------------------------
209
210
211 class KnowtationMergeHook:
212 """Pre-merge hook that fires the Knowtation consolidation daemon.
213
214 Implements :class:`muse.core.merge_hooks.MergeHookPlugin`.
215
216 Behaviour:
217
218 * ``consolidate=False`` (default) — return a no-op result with
219 ``fired=False`` and ``out_of_band=False``. The hook does no I/O
220 and reads no environment variables.
221 * ``consolidate=True`` — read and validate
222 :data:`ENV_PORT`, build ``http://localhost:<port>/api/v1/memory/consolidate``
223 from the port int (no string interpolation from user input),
224 spawn a daemon thread that POSTs an empty JSON body to that URL,
225 and return ``fired=True, out_of_band=True``. Any subsequent
226 failure inside the daemon thread is logged but never raised.
227
228 The hook itself never raises — port validation failures and
229 exceptions inside the dispatch path are converted to a
230 :class:`MergeHookResult` with the failure message in the
231 ``error`` field.
232 """
233
234 def pre_merge_hook(
235 self,
236 root: pathlib.Path,
237 ours_commit_id: str,
238 theirs_commit_id: str,
239 branch: str,
240 *,
241 consolidate: bool = False,
242 ) -> MergeHookResult:
243 """Dispatch the consolidation request and return a status record.
244
245 Args:
246 root: Repository root directory (currently unused; reserved
247 for future hooks that need to read ``.muse/`` state).
248 ours_commit_id: HEAD commit before merge (currently unused;
249 reserved for richer payloads in a later phase).
250 theirs_commit_id: HEAD of the merged-in branch (currently
251 unused; reserved for richer payloads in a later phase).
252 branch: Name of the merged-in branch (currently unused;
253 reserved for richer payloads in a later phase).
254 consolidate: When ``False``, return a no-op
255 :class:`MergeHookResult` with ``fired=False`` and do no
256 I/O. When ``True``, dispatch the consolidation HTTP
257 POST in a background thread and return immediately.
258
259 Returns:
260 A :class:`MergeHookResult` describing the dispatch outcome.
261 Never raises.
262 """
263 del root, ours_commit_id, theirs_commit_id, branch
264
265 if not consolidate:
266 return MergeHookResult(
267 hook_name=HOOK_NAME,
268 fired=False,
269 out_of_band=False,
270 message="--consolidate not requested",
271 error=None,
272 )
273
274 port, port_error = _resolve_port()
275 if port is None:
276 return MergeHookResult(
277 hook_name=HOOK_NAME,
278 fired=False,
279 out_of_band=False,
280 message="port validation failed; consolidation skipped",
281 error=port_error,
282 )
283
284 url = f"http://localhost:{port}{API_PATH}"
285
286 try:
287 _post_consolidate_in_background(url)
288 except Exception as exc: # noqa: BLE001 — must not propagate
289 logger.warning(
290 "knowtation-consolidate: failed to spawn background thread: %s",
291 exc,
292 )
293 return MergeHookResult(
294 hook_name=HOOK_NAME,
295 fired=False,
296 out_of_band=False,
297 message="failed to dispatch background HTTP call",
298 error=f"{type(exc).__name__}: {exc}",
299 )
300
301 return MergeHookResult(
302 hook_name=HOOK_NAME,
303 fired=True,
304 out_of_band=True,
305 message=f"dispatched POST {API_PATH} to localhost:{port} (background, {HTTP_TIMEOUT_SECONDS:.0f}s timeout)",
306 error=None,
307 )
308
309
310 # ---------------------------------------------------------------------------
311 # Registration
312 # ---------------------------------------------------------------------------
313
314
315 def register_knowtation_hook() -> None:
316 """Register a :class:`KnowtationMergeHook` instance under ``"knowtation"``.
317
318 Idempotent — calling this function more than once replaces the
319 previous registration with a fresh instance, so it is safe to call
320 eagerly at import time as well as lazily on first need.
321 """
322 get_hook_registry().register("knowtation", KnowtationMergeHook())
323
324
325 # Expose a parsable JSON-shaped hook result body — used in CLI tests to
326 # document the wire shape without redefining it.
327 def hook_result_to_json(result: MergeHookResult) -> str:
328 """Render a :class:`MergeHookResult` as a JSON string.
329
330 Helper used by the merge CLI to embed hook results in the
331 ``--format json`` output as the ``consolidation_hook`` field.
332
333 Args:
334 result: A :class:`MergeHookResult` returned from a hook.
335
336 Returns:
337 A compact JSON string with keys ``hook_name``, ``fired``,
338 ``out_of_band``, ``message``, ``error``.
339 """
340 return json.dumps(
341 {
342 "hook_name": result.hook_name,
343 "fired": result.fired,
344 "out_of_band": result.out_of_band,
345 "message": result.message,
346 "error": result.error,
347 },
348 sort_keys=True,
349 )
350
351
352 __all__ = [
353 "API_PATH",
354 "DEFAULT_PORT",
355 "ENV_PORT",
356 "HOOK_NAME",
357 "HTTP_TIMEOUT_SECONDS",
358 "KnowtationMergeHook",
359 "PORT_MAX",
360 "PORT_MIN",
361 "hook_result_to_json",
362 "register_knowtation_hook",
363 ]
File History 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 32 days ago