velophex 0.2.0__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.
Files changed (43) hide show
  1. velophex/__init__.py +38 -0
  2. velophex/_json.py +297 -0
  3. velophex/agents/__init__.py +130 -0
  4. velophex/agents/_errors.py +75 -0
  5. velophex/agents/_loop.py +436 -0
  6. velophex/agents/_tools.py +168 -0
  7. velophex/agents/_tracing.py +87 -0
  8. velophex/agents/testing.py +249 -0
  9. velophex/cli/__init__.py +5 -0
  10. velophex/cli/main.py +548 -0
  11. velophex/cli/publish.py +152 -0
  12. velophex/cli/sync.py +90 -0
  13. velophex/cli/templates.py +126 -0
  14. velophex/contracts/__init__.py +103 -0
  15. velophex/contracts/_model.py +26 -0
  16. velophex/contracts/agents.py +110 -0
  17. velophex/contracts/diagnostics.py +157 -0
  18. velophex/contracts/executor.py +1921 -0
  19. velophex/contracts/package.py +391 -0
  20. velophex/contracts/project.py +1008 -0
  21. velophex/contracts/runtimes.py +226 -0
  22. velophex/contracts/signin.py +53 -0
  23. velophex/contracts/validation.py +158 -0
  24. velophex/entrypoints.py +329 -0
  25. velophex/errors.py +44 -0
  26. velophex/packaging.py +465 -0
  27. velophex/py.typed +0 -0
  28. velophex/runtime/__init__.py +471 -0
  29. velophex/runtime/_session.py +192 -0
  30. velophex/runtime/client.py +957 -0
  31. velophex/runtime/models.py +326 -0
  32. velophex/signing.py +425 -0
  33. velophex/worker/__init__.py +27 -0
  34. velophex/worker/__main__.py +118 -0
  35. velophex/worker/framing.py +248 -0
  36. velophex/worker/loader.py +105 -0
  37. velophex/worker/outbox.py +147 -0
  38. velophex/worker/session.py +519 -0
  39. velophex-0.2.0.dist-info/METADATA +313 -0
  40. velophex-0.2.0.dist-info/RECORD +43 -0
  41. velophex-0.2.0.dist-info/WHEEL +4 -0
  42. velophex-0.2.0.dist-info/entry_points.txt +2 -0
  43. velophex-0.2.0.dist-info/licenses/LICENSE +21 -0
velophex/signing.py ADDED
@@ -0,0 +1,425 @@
1
+ """Detached package signing: ``velophex/signature.json`` (ADR-P003).
2
+
3
+ This is the Python twin of ``Velophex.Workflow.Security.Signing.DetachedPackageSigner``, and it
4
+ exists so that a package the CLI publishes can be trusted by the same pinned-certificate check
5
+ the Orchestrator and the Runner already apply to a Studio package. What it writes is read by
6
+ ``VeloPhex.Platform.Contracts.Packages.PackageSignatureVerifier``, which fails closed, so every
7
+ detail here is a compatibility requirement rather than a choice:
8
+
9
+ * The signed bytes are :func:`create_payload`, not the bare hash. Signing the package's identity
10
+ alongside the hash is what stops a valid signature being lifted onto another package.
11
+ * The identity in the document comes from the manifest, never from a CLI argument: the verifier
12
+ refuses a document whose ``packageId``/``packageVersion`` differ from the manifest's, so an
13
+ argument could only ever produce a package that is refused on upload.
14
+ * ECDSA signatures are IEEE P1363 (fixed-width ``r || s``), because that is what .NET's
15
+ ``ECDsa.VerifyData`` reads; :mod:`cryptography` produces DER and has to be converted.
16
+
17
+ ``cryptography`` is imported lazily and is an optional dependency (``velophex[signing]``). Only a
18
+ publisher signs; a robot only ever verifies, and ADR-P008 keeps its environment minimal.
19
+
20
+ The private key never leaves :class:`SigningKey`. Nothing in this module formats it, puts it in a
21
+ message or lets a third-party exception carrying it escape.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import base64
27
+ import hashlib
28
+ import zipfile
29
+ from dataclasses import dataclass, field
30
+ from datetime import UTC, datetime
31
+ from io import BytesIO
32
+ from pathlib import Path
33
+ from types import SimpleNamespace
34
+ from typing import Any, Final
35
+
36
+ from velophex import _json
37
+ from velophex.contracts.package import PackageLayout
38
+ from velophex.errors import VelophexError
39
+ from velophex.packaging import ENTRY_TIMESTAMP
40
+
41
+ __all__ = [
42
+ "CURRENT_FORMAT_VERSION",
43
+ "ECDSA_SHA512",
44
+ "FORMAT_NAME",
45
+ "MINIMUM_FORMAT_VERSION",
46
+ "RSA_PSS_SHA512",
47
+ "SIGNED_AT_FORMAT",
48
+ "SIGNING_EXTRA_HINT",
49
+ "SigningError",
50
+ "SigningKey",
51
+ "create_payload",
52
+ "create_signature_document",
53
+ "format_signed_at",
54
+ "load_signing_key",
55
+ "sign_package_bytes",
56
+ "sign_package_file",
57
+ ]
58
+
59
+ FORMAT_NAME: Final = "velophex.package-signature"
60
+
61
+ #: Format 2 signs the signing time (ADR-P012): the verifier checks the certificate as of
62
+ #: ``signedAt`` instead of now, so the signature outlives the certificate. Format 1 is still
63
+ #: verified everywhere but dies with its certificate; nothing writes it any more.
64
+ CURRENT_FORMAT_VERSION: Final = 2
65
+ MINIMUM_FORMAT_VERSION: Final = 1
66
+
67
+ #: The one spelling of ``signedAt``: UTC to the second, ``Z`` suffix, no fraction, no offset.
68
+ #: Byte for byte ``PackageSignature.SignedAtFormat``; the payload carries it verbatim.
69
+ SIGNED_AT_FORMAT: Final = "%Y-%m-%dT%H:%M:%SZ"
70
+
71
+ #: RSA with PSS padding over SHA-512; the salt is the hash length, as .NET's ``RSASignaturePadding.Pss``.
72
+ RSA_PSS_SHA512: Final = "RSASSA-PSS-SHA512"
73
+
74
+ #: ECDSA over SHA-512, signature in IEEE P1363 form.
75
+ ECDSA_SHA512: Final = "ECDSA-SHA512"
76
+
77
+ #: What a signature document larger than this is: not one of ours. Mirrors the verifier's limit.
78
+ MAXIMUM_DOCUMENT_BYTES: Final = 64 * 1024
79
+
80
+ #: The verifier refuses a larger certificate unread, so refuse it here rather than on upload.
81
+ MAXIMUM_CERTIFICATE_BYTES: Final = 16 * 1024
82
+
83
+ SIGNING_EXTRA_HINT: Final = (
84
+ "Signing needs the 'cryptography' package, which the plain SDK install deliberately leaves out. "
85
+ 'Install it with: uv add "velophex[signing]" (or: uv pip install "cryptography>=43,<47")'
86
+ )
87
+
88
+
89
+ class SigningError(VelophexError):
90
+ """Signing could not be completed.
91
+
92
+ Carries only what the operator has to act on. A password or a private key is never part of
93
+ the message, and a third-party exception that might quote one is never chained to it.
94
+ """
95
+
96
+
97
+ @dataclass(frozen=True, slots=True)
98
+ class SigningKey:
99
+ """A signer's certificate and the private key that goes with it.
100
+
101
+ Everything a caller needs for display or for a policy check is copied out at load time, so
102
+ the key object itself is only ever touched by :func:`_sign` and never reaches a ``repr``.
103
+ """
104
+
105
+ #: The certificate's DER bytes: what the document carries, Base64, and what is thumbprinted.
106
+ certificate_der: bytes
107
+
108
+ #: RFC 4514 subject, e.g. ``CN=VeloPhex Publisher``, for the line the CLI prints.
109
+ subject: str
110
+
111
+ not_before: datetime
112
+
113
+ not_after: datetime
114
+
115
+ #: :data:`RSA_PSS_SHA512` or :data:`ECDSA_SHA512`, decided by the key's type.
116
+ algorithm: str
117
+
118
+ private_key: Any = field(repr=False)
119
+
120
+ @property
121
+ def thumbprint(self) -> str:
122
+ """Upper-case hex SHA-256 of the DER bytes: the value a verifier pins."""
123
+ return hashlib.sha256(self.certificate_der).hexdigest().upper()
124
+
125
+ def is_valid_at(self, moment: datetime) -> bool:
126
+ """Whether *moment* falls inside the certificate's validity period.
127
+
128
+ The verifier rejects a signature from a certificate outside it, so a publisher is better
129
+ told before the package is uploaded than after.
130
+ """
131
+ return self.not_before <= moment <= self.not_after
132
+
133
+
134
+ def format_signed_at(moment: datetime) -> str:
135
+ """*moment* in the one canonical spelling of ``signedAt``, UTC and truncated to the second.
136
+
137
+ Byte for byte ``PackageSignature.FormatSignedAt``. A naive datetime is taken as UTC.
138
+ """
139
+ return _utc(moment).replace(microsecond=0).strftime(SIGNED_AT_FORMAT)
140
+
141
+
142
+ def create_payload(
143
+ package_id: str,
144
+ package_version: str,
145
+ manifest_sha512: str,
146
+ signed_at: datetime | None = None,
147
+ ) -> bytes:
148
+ """Build the exact bytes a signer signs and a verifier checks.
149
+
150
+ Byte for byte ``PackageSignature.CreatePayload``: a domain-separation prefix line, then the
151
+ id, the version and the manifest hash, one per line, lower-cased, LF separated, trailing LF.
152
+ The version is lower-cased and nothing more - versions are SemVer 2.0 without build metadata
153
+ (ADR-P011), so the text is already normalized and normalizing again here would sign bytes no
154
+ .NET verifier would reconstruct.
155
+
156
+ With *signed_at* the payload is **format 2**: the prefix says ``/2`` and a fifth line carries
157
+ the signing time as :func:`format_signed_at` writes it, verbatim (not lower-cased). Without it
158
+ the payload is format 1, which no signer writes any more and every verifier still reads.
159
+ """
160
+ for name, value in (
161
+ ("package id", package_id),
162
+ ("package version", package_version),
163
+ ("manifest hash", manifest_sha512),
164
+ ):
165
+ if not value or not value.strip():
166
+ raise SigningError(f"The {name} is required to sign a package.")
167
+
168
+ format_version = MINIMUM_FORMAT_VERSION if signed_at is None else CURRENT_FORMAT_VERSION
169
+ fifth_line = "" if signed_at is None else f"{format_signed_at(signed_at)}\n"
170
+
171
+ return (
172
+ f"{FORMAT_NAME}/{format_version}\n"
173
+ f"{package_id.lower()}\n"
174
+ f"{package_version.lower()}\n"
175
+ f"{manifest_sha512.lower()}\n"
176
+ f"{fifth_line}"
177
+ ).encode()
178
+
179
+
180
+ def load_signing_key(pkcs12_path: str | Path, password: bytes | None) -> SigningKey:
181
+ """Load a signer from a PKCS#12 (``.pfx``/``.p12``) file.
182
+
183
+ *password* is ``None`` for an unprotected file. It is read by the caller from an environment
184
+ variable or from stdin - never from a command-line argument, which every process listing on
185
+ the machine can see.
186
+ """
187
+ crypto = _cryptography()
188
+ path = Path(pkcs12_path)
189
+
190
+ try:
191
+ payload = path.read_bytes()
192
+ except OSError as error:
193
+ raise SigningError(f"The signing key file '{path}' could not be read: {error.strerror}.") from None
194
+
195
+ try:
196
+ private_key, certificate, _ = crypto.pkcs12.load_key_and_certificates(payload, password)
197
+ except Exception:
198
+ # Never chain: the underlying exception is the only thing in this module that has ever
199
+ # been anywhere near the password, and a traceback in CI output is a disclosure.
200
+ raise SigningError(
201
+ f"'{path}' could not be opened as a PKCS#12 signing key. Check the file and the password."
202
+ ) from None
203
+
204
+ if certificate is None:
205
+ raise SigningError(f"'{path}' carries no certificate, so there is nothing to sign with.")
206
+
207
+ if isinstance(private_key, crypto.rsa.RSAPrivateKey):
208
+ algorithm = RSA_PSS_SHA512
209
+ elif isinstance(private_key, crypto.ec.EllipticCurvePrivateKey):
210
+ algorithm = ECDSA_SHA512
211
+ else:
212
+ raise SigningError(
213
+ f"'{path}' has no RSA or ECDSA private key; the platform signs with "
214
+ f"{RSA_PSS_SHA512} or {ECDSA_SHA512} only."
215
+ )
216
+
217
+ certificate_der = certificate.public_bytes(crypto.Encoding.DER)
218
+
219
+ if len(certificate_der) > MAXIMUM_CERTIFICATE_BYTES:
220
+ raise SigningError(
221
+ f"The signer certificate is {len(certificate_der)} bytes; verifiers refuse anything "
222
+ f"over {MAXIMUM_CERTIFICATE_BYTES}."
223
+ )
224
+
225
+ _require_may_sign(crypto, certificate, path)
226
+
227
+ return SigningKey(
228
+ certificate_der=certificate_der,
229
+ subject=certificate.subject.rfc4514_string(),
230
+ not_before=certificate.not_valid_before_utc,
231
+ not_after=certificate.not_valid_after_utc,
232
+ algorithm=algorithm,
233
+ private_key=private_key,
234
+ )
235
+
236
+
237
+ def create_signature_document(
238
+ manifest_bytes: bytes,
239
+ key: SigningKey,
240
+ signed_at: datetime | None = None,
241
+ ) -> bytes:
242
+ """Sign *manifest_bytes* and return the format-2 ``velophex/signature.json`` document.
243
+
244
+ *manifest_bytes* must be ``velophex/package-manifest.json`` exactly as stored in the archive:
245
+ the verifier hashes what it reads out of the package, so anything re-serialized here would
246
+ hash to a different value. *signed_at* defaults to now; the document's ``signedAt`` member is
247
+ the same text the payload carries, so a verifier reconstructs the signed bytes exactly.
248
+ """
249
+ package_id, package_version = _read_identity(manifest_bytes)
250
+ manifest_sha512 = hashlib.sha512(manifest_bytes).hexdigest()
251
+ moment = datetime.now(UTC) if signed_at is None else _utc(signed_at)
252
+
253
+ if not key.is_valid_at(moment):
254
+ raise SigningError(
255
+ f"The signer certificate '{key.subject}' is not valid at {format_signed_at(moment)}; "
256
+ "a verifier would refuse the signature."
257
+ )
258
+
259
+ signature = _sign(create_payload(package_id, package_version, manifest_sha512, moment), key)
260
+
261
+ document = {
262
+ "format": FORMAT_NAME,
263
+ "formatVersion": CURRENT_FORMAT_VERSION,
264
+ "packageId": package_id,
265
+ "packageVersion": package_version,
266
+ "manifestSha512": manifest_sha512,
267
+ "algorithm": key.algorithm,
268
+ # The public certificate only. A PKCS#12 holds the private key too; it stops here.
269
+ "certificate": base64.b64encode(key.certificate_der).decode("ascii"),
270
+ "signedAt": format_signed_at(moment),
271
+ "signature": base64.b64encode(signature).decode("ascii"),
272
+ }
273
+
274
+ # Member order, indent and escaping of System.Text.Json with WriteIndented, in the order
275
+ # PackageSignature declares its members; the reserved timestampToken is not written.
276
+ return _json.dumps_document(document).encode("utf-8")
277
+
278
+
279
+ def sign_package_bytes(package: bytes, key: SigningKey, signed_at: datetime | None = None) -> bytes:
280
+ """Return *package* with ``velophex/signature.json`` added.
281
+
282
+ The signature is appended as a new archive entry, which leaves every existing entry - the
283
+ manifest above all - byte for byte where it was. A signature that changed the bytes it
284
+ covers could never verify.
285
+ """
286
+ with zipfile.ZipFile(BytesIO(package)) as archive:
287
+ names = set(archive.namelist())
288
+
289
+ if PackageLayout.SIGNATURE_PATH in names:
290
+ raise SigningError("The package is already signed; build it again before signing.")
291
+
292
+ if PackageLayout.MANIFEST_PATH not in names:
293
+ raise SigningError(f"The package has no '{PackageLayout.MANIFEST_PATH}' to sign.")
294
+
295
+ manifest_bytes = archive.read(PackageLayout.MANIFEST_PATH)
296
+
297
+ document = create_signature_document(manifest_bytes, key, signed_at)
298
+
299
+ if len(document) > MAXIMUM_DOCUMENT_BYTES:
300
+ raise SigningError(f"The signature document is larger than {MAXIMUM_DOCUMENT_BYTES} bytes.")
301
+
302
+ buffer = BytesIO(package)
303
+
304
+ with zipfile.ZipFile(buffer, "a", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
305
+ info = zipfile.ZipInfo(PackageLayout.SIGNATURE_PATH, date_time=ENTRY_TIMESTAMP)
306
+ info.compress_type = zipfile.ZIP_DEFLATED
307
+ info.external_attr = 0o600 << 16
308
+ archive.writestr(info, document)
309
+
310
+ return buffer.getvalue()
311
+
312
+
313
+ def sign_package_file(package_path: str | Path, key: SigningKey) -> Path:
314
+ """Sign the ``.nupkg`` at *package_path* in place and return its path."""
315
+ path = Path(package_path)
316
+ path.write_bytes(sign_package_bytes(path.read_bytes(), key))
317
+ return path
318
+
319
+
320
+ # --------------------------------------------------------------------------- internals
321
+
322
+
323
+ def _utc(moment: datetime) -> datetime:
324
+ """*moment* as an aware UTC datetime; a naive one is taken as UTC already."""
325
+ return moment.replace(tzinfo=UTC) if moment.tzinfo is None else moment.astimezone(UTC)
326
+
327
+
328
+ def _read_identity(manifest_bytes: bytes) -> tuple[str, str]:
329
+ """Read the package id and version the manifest itself declares.
330
+
331
+ Mirrors ``PackageManifestIdentity.TryRead``: only the identity is needed, and taking it from
332
+ the manifest is what makes the verifier's equality check pass.
333
+ """
334
+ try:
335
+ document = _json.loads(manifest_bytes)
336
+ except ValueError as error:
337
+ raise SigningError(f"The package manifest is not valid JSON: {error}") from None
338
+
339
+ if not isinstance(document, dict):
340
+ raise SigningError("The package manifest is not a JSON object.")
341
+
342
+ package_id = document.get("packageId")
343
+ package_version = document.get("packageVersion")
344
+
345
+ if not isinstance(package_id, str) or not isinstance(package_version, str):
346
+ raise SigningError("The package manifest has no 'packageId' and 'packageVersion' to sign.")
347
+
348
+ return package_id, package_version
349
+
350
+
351
+ def _require_may_sign(crypto: SimpleNamespace, certificate: Any, path: Path) -> None:
352
+ """Refuse a certificate whose key usage forbids signing, as the verifier would.
353
+
354
+ A certificate with no key-usage extension is allowed: the verifier only enforces the
355
+ extension when it is there, and refusing more here would reject signers it accepts.
356
+ """
357
+ try:
358
+ usage = certificate.extensions.get_extension_for_class(crypto.x509.KeyUsage).value
359
+ except crypto.x509.ExtensionNotFound:
360
+ return
361
+
362
+ if not usage.digital_signature:
363
+ raise SigningError(
364
+ f"The certificate in '{path}' has a key usage that forbids digital signatures, "
365
+ "so every verifier would refuse its signature."
366
+ )
367
+
368
+
369
+ def _sign(payload: bytes, key: SigningKey) -> bytes:
370
+ crypto = _cryptography()
371
+
372
+ if key.algorithm == RSA_PSS_SHA512:
373
+ return key.private_key.sign(
374
+ payload,
375
+ crypto.padding.PSS(
376
+ mgf=crypto.padding.MGF1(crypto.hashes.SHA512()),
377
+ # .NET's RSASignaturePadding.Pss salts with the hash length; a verifier that
378
+ # expects 64 bytes of salt will not accept any other length.
379
+ salt_length=crypto.hashes.SHA512.digest_size,
380
+ ),
381
+ crypto.hashes.SHA512(),
382
+ )
383
+
384
+ der = key.private_key.sign(payload, crypto.ec.ECDSA(crypto.hashes.SHA512()))
385
+
386
+ return _to_p1363(der, key.private_key.curve.key_size, crypto)
387
+
388
+
389
+ def _to_p1363(der_signature: bytes, key_size_bits: int, crypto: SimpleNamespace) -> bytes:
390
+ """Convert a DER ``SEQUENCE { r, s }`` to the fixed-width ``r || s`` .NET reads.
391
+
392
+ ``ECDsa.VerifyData`` defaults to IEEE P1363, so a DER signature - which is what
393
+ :mod:`cryptography` returns - is simply not a signature as far as the verifier is concerned.
394
+ """
395
+ r, s = crypto.decode_dss_signature(der_signature)
396
+ width = (key_size_bits + 7) // 8
397
+
398
+ return r.to_bytes(width, "big") + s.to_bytes(width, "big")
399
+
400
+
401
+ def _cryptography() -> SimpleNamespace:
402
+ """Import the pieces of ``cryptography`` signing needs, or say how to install it.
403
+
404
+ One import site, so the optional dependency turns into one actionable sentence instead of a
405
+ traceback from wherever it happened to be first touched.
406
+ """
407
+ try:
408
+ from cryptography import x509
409
+ from cryptography.hazmat.primitives import hashes
410
+ from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa
411
+ from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
412
+ from cryptography.hazmat.primitives.serialization import Encoding, pkcs12
413
+ except ImportError:
414
+ raise SigningError(SIGNING_EXTRA_HINT) from None
415
+
416
+ return SimpleNamespace(
417
+ Encoding=Encoding,
418
+ decode_dss_signature=decode_dss_signature,
419
+ ec=ec,
420
+ hashes=hashes,
421
+ padding=padding,
422
+ pkcs12=pkcs12,
423
+ rsa=rsa,
424
+ x509=x509,
425
+ )
@@ -0,0 +1,27 @@
1
+ """The VeloPhex Python executor: ``python -m velophex.worker``.
2
+
3
+ The Runner (or Studio, or ``velophex run``) starts one worker process per automation run and
4
+ talks to it over the process's stdin and stdout with the executor protocol (ADR-P005). stdout
5
+ belongs to the protocol; everything a handler prints goes to stderr, which the parent captures
6
+ as free-text diagnostics.
7
+
8
+ Modules:
9
+
10
+ * :mod:`velophex.worker.framing` - length-prefixed, HMAC-authenticated frames
11
+ * :mod:`velophex.worker.outbox` - outgoing messages and the ``logging`` bridge
12
+ * :mod:`velophex.worker.loader` - ``project.json`` to an imported handler
13
+ * :mod:`velophex.worker.session` - the conversation itself
14
+ """
15
+
16
+ from velophex.worker.framing import FrameReader, FrameWriter, take_session_key_from_environment
17
+ from velophex.worker.outbox import ExecutorOutbox, WorkerLogHandler
18
+ from velophex.worker.session import WorkerSession
19
+
20
+ __all__ = [
21
+ "ExecutorOutbox",
22
+ "FrameReader",
23
+ "FrameWriter",
24
+ "WorkerLogHandler",
25
+ "WorkerSession",
26
+ "take_session_key_from_environment",
27
+ ]
@@ -0,0 +1,118 @@
1
+ """``python -m velophex.worker`` - the process the Runner launches for a Python automation.
2
+
3
+ Exit codes match the workflow executor: ``0`` the session ended, ``2`` a protocol violation,
4
+ ``3`` no session key. Anything else is a crash, which the parent reports as
5
+ ``Application / EXECUTOR_CRASHED``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import platform
12
+ import sys
13
+ from typing import BinaryIO
14
+
15
+ from velophex import __version__
16
+ from velophex.contracts.executor import SESSION_KEY_ENVIRONMENT_VARIABLE, ExecutorFrameDirection
17
+ from velophex.worker.framing import FrameReader, FrameWriter, take_session_key_from_environment
18
+ from velophex.worker.outbox import ExecutorOutbox
19
+ from velophex.worker.session import EXIT_PROTOCOL_VIOLATION, WorkerSession
20
+
21
+ EXIT_NO_SESSION_KEY = 3
22
+ _STD_INPUT_HANDLE = -10
23
+ _STD_OUTPUT_HANDLE = -11
24
+
25
+
26
+ def _set_std_handle(which: int, fd: int) -> None:
27
+ """Point a Win32 standard handle at *fd*: ``dup2`` changes the CRT descriptor only."""
28
+ if sys.platform == "win32":
29
+ import ctypes
30
+ import msvcrt
31
+
32
+ ctypes.windll.kernel32.SetStdHandle(which, msvcrt.get_osfhandle(fd))
33
+
34
+
35
+ def _take_command_stream() -> BinaryIO:
36
+ """Move the parent's frame pipe off standard input and give standard input the null device.
37
+
38
+ The same reasoning as :func:`_take_frame_stream`, in the other direction: a child process
39
+ inherits fd 0 / ``STD_INPUT_HANDLE``, and one that reads it (``input()`` in a subprocess, a
40
+ CLI prompting for a password) would consume the parent's frames - a stop request among
41
+ them. So the pipe moves to a private, non-inheritable descriptor, and anything the
42
+ automation or its children read from standard input gets end-of-file.
43
+ """
44
+ command_fd = os.dup(0) # PEP 446: non-inheritable
45
+ null = os.open(os.devnull, os.O_RDONLY)
46
+ os.dup2(null, 0)
47
+ os.close(null)
48
+ _set_std_handle(_STD_INPUT_HANDLE, 0)
49
+ sys.stdin = open(os.devnull, encoding="utf-8") # noqa: SIM115 - lives as long as the process
50
+
51
+ # Unbuffered: the listener thread stays blocked in a read after the session ends, and a
52
+ # BufferedReader's lock held by a daemon thread is a fatal error at interpreter shutdown.
53
+ return open(command_fd, "rb", buffering=0)
54
+
55
+
56
+ def _take_frame_stream() -> BinaryIO:
57
+ """Move the frame pipe off standard output and point standard output at stderr.
58
+
59
+ Rebinding ``sys.stdout`` alone is not enough: a child process (``os.system``, ``subprocess``
60
+ without ``stdout=``), a C extension or a direct ``WriteFile`` on the standard handle writes
61
+ to fd 1 / ``STD_OUTPUT_HANDLE``, which would still be the frame pipe and corrupt the stream.
62
+ So the pipe moves to a private, non-inheritable descriptor, and fd 1 - plus the Win32
63
+ standard handle, which ``dup2`` does not touch - become stderr. A grandchild that outlives
64
+ the worker therefore cannot hold the pipe open either: the parent sees end-of-file when
65
+ the worker exits.
66
+ """
67
+ frame_fd = os.dup(1) # PEP 446: non-inheritable
68
+
69
+ try:
70
+ os.dup2(2, 1)
71
+ except OSError:
72
+ # No usable stderr (pythonw launched without one): standard output goes nowhere.
73
+ null = os.open(os.devnull, os.O_WRONLY)
74
+ os.dup2(null, 1)
75
+ os.close(null)
76
+
77
+ _set_std_handle(_STD_OUTPUT_HANDLE, 1)
78
+
79
+ # Anything automation code prints goes to stderr, so a stray print() is diagnostics.
80
+ sys.stdout = sys.stderr
81
+ return open(frame_fd, "wb", buffering=0)
82
+
83
+
84
+ def main() -> int:
85
+ """Serve one executor session on stdin and stdout."""
86
+ key = take_session_key_from_environment()
87
+
88
+ if key is None:
89
+ print(
90
+ f"{SESSION_KEY_ENVIRONMENT_VARIABLE} is missing or invalid; "
91
+ "the worker must be started by a VeloPhex parent.",
92
+ file=sys.stderr,
93
+ )
94
+ return EXIT_NO_SESSION_KEY
95
+
96
+ reader = FrameReader(_take_command_stream(), key, ExecutorFrameDirection.PARENT_TO_EXECUTOR)
97
+ writer = FrameWriter(_take_frame_stream(), key, ExecutorFrameDirection.EXECUTOR_TO_PARENT)
98
+
99
+ session = WorkerSession(
100
+ reader,
101
+ ExecutorOutbox(writer),
102
+ runtime_version=platform.python_version(),
103
+ worker_version=__version__,
104
+ diagnostics=sys.stderr,
105
+ )
106
+
107
+ try:
108
+ return session.run()
109
+ except KeyboardInterrupt:
110
+ return EXIT_PROTOCOL_VIOLATION
111
+ finally:
112
+ # The key protected this process only; it must not outlive the session in memory
113
+ # any longer than necessary.
114
+ del key
115
+
116
+
117
+ if __name__ == "__main__":
118
+ raise SystemExit(main())