python-plugin 0.1.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.
@@ -0,0 +1,70 @@
1
+ """Parse hclog log lines into structured ``logging.LogRecord`` calls.
2
+
3
+ go-plugin pipes plugin stderr through go-hclog. hclog has two output modes:
4
+
5
+ * JSON: ``{"@level":"info","@message":"...","@module":"foo","@timestamp":"...",...}``
6
+ * Pretty: ``2025-01-01T12:00:00.000Z [INFO] module: msg: key=value key2="quoted"``
7
+
8
+ We try JSON first (cheap discriminator: starts with ``{``) and fall back to
9
+ the pretty regex. Anything we can't parse is logged at INFO with the raw
10
+ text. We always emit on the caller's logger so they can hook formatters.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ import re
17
+ from typing import Any
18
+
19
+ _LEVELS = {
20
+ "trace": logging.DEBUG, # logging has no TRACE; map to DEBUG
21
+ "debug": logging.DEBUG,
22
+ "info": logging.INFO,
23
+ "warn": logging.WARNING,
24
+ "warning": logging.WARNING,
25
+ "error": logging.ERROR,
26
+ "fatal": logging.CRITICAL,
27
+ "critical": logging.CRITICAL,
28
+ }
29
+
30
+ # Pretty format: optional timestamp, then [LEVEL] module: message ...
31
+ _PRETTY = re.compile(
32
+ r"^(?P<ts>\S+)?\s*\[(?P<level>\w+)\]\s*(?P<module>[^:]+):\s*(?P<rest>.*)$"
33
+ )
34
+
35
+
36
+ def emit(logger: logging.Logger, raw: str) -> None:
37
+ """Parse ``raw`` and emit on ``logger`` at the matching level."""
38
+ raw = raw.rstrip("\r\n")
39
+ if not raw:
40
+ return
41
+
42
+ if raw.lstrip().startswith("{"):
43
+ rec = _parse_json(raw)
44
+ if rec is not None:
45
+ level, msg, module, extra = rec
46
+ logger.log(level, msg, extra={"plugin": module, "fields": extra})
47
+ return
48
+
49
+ m = _PRETTY.match(raw)
50
+ if m is not None:
51
+ level = _LEVELS.get(m["level"].lower(), logging.INFO)
52
+ module = m["module"].strip()
53
+ logger.log(level, m["rest"].strip(), extra={"plugin": module})
54
+ return
55
+
56
+ logger.info(raw)
57
+
58
+
59
+ def _parse_json(raw: str) -> tuple[int, str, str, dict[str, Any]] | None:
60
+ try:
61
+ obj = json.loads(raw)
62
+ except (json.JSONDecodeError, ValueError):
63
+ return None
64
+ if not isinstance(obj, dict):
65
+ return None
66
+ level = _LEVELS.get(str(obj.pop("@level", "info")).lower(), logging.INFO)
67
+ msg = str(obj.pop("@message", ""))
68
+ module = str(obj.pop("@module", ""))
69
+ obj.pop("@timestamp", None)
70
+ return level, msg, module, obj
pyplugin/mtls.py ADDED
@@ -0,0 +1,169 @@
1
+ """AutoMTLS — ephemeral certificate generation matching go-plugin/mtls.go EXACTLY.
2
+
3
+ go-plugin uses ECDSA P-521 + SHA-512. Python's ``ssl`` module (OpenSSL,
4
+ which grpclib runs on) supports this natively, so we can match
5
+ go-plugin's cert template byte-for-byte:
6
+
7
+ * Curve: ECDSA P-521 (SECP521R1)
8
+ * Signature: ECDSA-SHA-512
9
+ * CN/SAN: ``localhost``
10
+ * O: ``HashiCorp``
11
+ * IsCA: true, BasicConstraints critical
12
+ * KeyUsage: digital signature | key encipherment | key agreement | cert sign
13
+ * ExtKeyUsage: clientAuth + serverAuth
14
+ * Validity: NotBefore -30s, NotAfter +262980h (~30 years)
15
+
16
+ The host's leaf cert is sent to the plugin via ``PLUGIN_CLIENT_CERT`` (PEM);
17
+ the plugin's leaf is returned as base64.RawStdEncoding(DER) in handshake
18
+ field 6.
19
+
20
+ Both sides pin the peer's leaf cert as the trust root and use
21
+ ``RequireAndVerifyClientCert`` semantics.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import base64
26
+ import datetime
27
+ import secrets
28
+ import ssl
29
+ from dataclasses import dataclass
30
+
31
+ from cryptography import x509
32
+ from cryptography.hazmat.primitives import hashes, serialization
33
+ from cryptography.hazmat.primitives.asymmetric import ec
34
+ from cryptography.x509.oid import NameOID
35
+
36
+ _NOT_BEFORE_SKEW = datetime.timedelta(seconds=30)
37
+ _VALIDITY = datetime.timedelta(hours=262980)
38
+ _HOST = "localhost"
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Cert:
43
+ """A self-signed leaf usable as both the cert and its own CA (matches go-plugin)."""
44
+ cert_pem: bytes
45
+ key_pem: bytes
46
+ cert_der: bytes
47
+
48
+
49
+ def generate() -> Cert:
50
+ """Generate one ephemeral cert+key pair, byte-compatible with go-plugin's ``generateCert``."""
51
+ key = ec.generate_private_key(ec.SECP521R1())
52
+
53
+ # Match Go's serial: random in [0, 2^128).
54
+ serial = int.from_bytes(secrets.token_bytes(16), "big")
55
+
56
+ now = datetime.datetime.now(datetime.timezone.utc)
57
+ subject = issuer = x509.Name([
58
+ x509.NameAttribute(NameOID.COMMON_NAME, _HOST),
59
+ x509.NameAttribute(NameOID.ORGANIZATION_NAME, "HashiCorp"),
60
+ ])
61
+
62
+ builder = (
63
+ x509.CertificateBuilder()
64
+ .subject_name(subject)
65
+ .issuer_name(issuer)
66
+ .public_key(key.public_key())
67
+ .serial_number(serial)
68
+ .not_valid_before(now - _NOT_BEFORE_SKEW)
69
+ .not_valid_after(now + _VALIDITY)
70
+ .add_extension(x509.SubjectAlternativeName([x509.DNSName(_HOST)]), critical=False)
71
+ .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
72
+ .add_extension(
73
+ x509.KeyUsage(
74
+ digital_signature=True,
75
+ content_commitment=False,
76
+ key_encipherment=True,
77
+ data_encipherment=False,
78
+ key_agreement=True,
79
+ key_cert_sign=True,
80
+ crl_sign=False,
81
+ encipher_only=False,
82
+ decipher_only=False,
83
+ ),
84
+ critical=False,
85
+ )
86
+ .add_extension(
87
+ x509.ExtendedKeyUsage([
88
+ x509.ExtendedKeyUsageOID.CLIENT_AUTH,
89
+ x509.ExtendedKeyUsageOID.SERVER_AUTH,
90
+ ]),
91
+ critical=False,
92
+ )
93
+ )
94
+
95
+ cert = builder.sign(private_key=key, algorithm=hashes.SHA512())
96
+ cert_der = cert.public_bytes(serialization.Encoding.DER)
97
+ cert_pem = cert.public_bytes(serialization.Encoding.PEM)
98
+ key_pem = key.private_bytes(
99
+ encoding=serialization.Encoding.PEM,
100
+ format=serialization.PrivateFormat.TraditionalOpenSSL, # "EC PRIVATE KEY"
101
+ encryption_algorithm=serialization.NoEncryption(),
102
+ )
103
+ return Cert(cert_pem=cert_pem, key_pem=key_pem, cert_der=cert_der)
104
+
105
+
106
+ def encode_handshake_cert(cert_der: bytes) -> str:
107
+ """Encode a leaf cert DER for handshake field 6: base64.RawStdEncoding (no padding)."""
108
+ return base64.b64encode(cert_der).rstrip(b"=").decode("ascii")
109
+
110
+
111
+ def decode_handshake_cert(b64: str) -> bytes:
112
+ """Inverse of :func:`encode_handshake_cert` — returns raw DER bytes."""
113
+ pad = "=" * (-len(b64) % 4)
114
+ return base64.b64decode(b64 + pad)
115
+
116
+
117
+ def der_to_pem(cert_der: bytes) -> bytes:
118
+ """Wrap a raw DER cert in a single PEM CERTIFICATE block."""
119
+ cert = x509.load_der_x509_certificate(cert_der)
120
+ return cert.public_bytes(serialization.Encoding.PEM)
121
+
122
+
123
+ def server_ssl_context(*, cert_pem: bytes, key_pem: bytes, peer_cert_pem: bytes) -> ssl.SSLContext:
124
+ """Build a server-side ``SSLContext`` mirroring go-plugin's tls.Config:
125
+
126
+ ``Certificates``, ``RequireAndVerifyClientCert``, ``ClientCAs=peer``,
127
+ ``RootCAs=peer``, ``MinVersion=TLS1.2``, ``ServerName=localhost``.
128
+ Uses ALPN ``h2`` for HTTP/2.
129
+ """
130
+ import tempfile
131
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
132
+ ctx.minimum_version = ssl.TLSVersion.TLSv1_2
133
+ ctx.verify_mode = ssl.CERT_REQUIRED
134
+ ctx.set_alpn_protocols(["h2"])
135
+ # SSLContext.load_* take file paths only — write to temp files.
136
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".pem") as cf, \
137
+ tempfile.NamedTemporaryFile(delete=False, suffix=".pem") as kf, \
138
+ tempfile.NamedTemporaryFile(delete=False, suffix=".pem") as rf:
139
+ cf.write(cert_pem); cf.flush()
140
+ kf.write(key_pem); kf.flush()
141
+ rf.write(peer_cert_pem); rf.flush()
142
+ ctx.load_cert_chain(certfile=cf.name, keyfile=kf.name)
143
+ ctx.load_verify_locations(cafile=rf.name)
144
+ return ctx
145
+
146
+
147
+ def client_ssl_context(*, cert_pem: bytes, key_pem: bytes, peer_cert_pem: bytes) -> ssl.SSLContext:
148
+ """Build a client-side ``SSLContext`` for AutoMTLS.
149
+
150
+ Trust root is the peer's cert; we present our own cert+key as client cert.
151
+ Hostname verification is **disabled** because (a) we're connecting over
152
+ unix sockets where there's no real hostname, and (b) we've already pinned
153
+ the exact peer cert, so hostname check would be redundant anyway.
154
+ """
155
+ import tempfile
156
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
157
+ ctx.minimum_version = ssl.TLSVersion.TLSv1_2
158
+ ctx.check_hostname = False
159
+ ctx.verify_mode = ssl.CERT_REQUIRED
160
+ ctx.set_alpn_protocols(["h2"])
161
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".pem") as cf, \
162
+ tempfile.NamedTemporaryFile(delete=False, suffix=".pem") as kf, \
163
+ tempfile.NamedTemporaryFile(delete=False, suffix=".pem") as rf:
164
+ cf.write(cert_pem); cf.flush()
165
+ kf.write(key_pem); kf.flush()
166
+ rf.write(peer_cert_pem); rf.flush()
167
+ ctx.load_cert_chain(certfile=cf.name, keyfile=kf.name)
168
+ ctx.load_verify_locations(cafile=rf.name)
169
+ return ctx
pyplugin/plugin.py ADDED
@@ -0,0 +1,38 @@
1
+ """Plugin ABC and type aliases (grpclib version).
2
+
3
+ A pyplugin author implements one ``Plugin`` subclass per service exposed.
4
+ ``servicers`` returns the grpclib ``Base`` instances to register on the
5
+ plugin-side server; ``stub`` builds a typed client stub on the host side
6
+ from an open ``grpclib.client.Channel``.
7
+
8
+ Mirrors go-plugin's ``GRPCPlugin`` interface — both methods receive the
9
+ ``GRPCBroker`` so plugins can request callbacks back into the host.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import abc
14
+ from typing import Any, Mapping, TYPE_CHECKING
15
+
16
+ from grpclib.client import Channel
17
+
18
+ if TYPE_CHECKING:
19
+ from .broker import GRPCBroker
20
+
21
+
22
+ class Plugin(abc.ABC):
23
+ """Abstract base for a plugin type."""
24
+
25
+ @abc.abstractmethod
26
+ def servicers(self, broker: "GRPCBroker") -> list:
27
+ """Return grpclib servicer instances to register on the plugin server.
28
+
29
+ Called once on the plugin side during ``serve()`` setup.
30
+ """
31
+
32
+ @abc.abstractmethod
33
+ def stub(self, broker: "GRPCBroker", channel: Channel) -> Any:
34
+ """Build a typed grpclib client stub from an open channel (host side)."""
35
+
36
+
37
+ PluginSet = Mapping[str, Plugin]
38
+ VersionedPlugins = Mapping[int, PluginSet]
pyplugin/process.py ADDED
@@ -0,0 +1,66 @@
1
+ """Subprocess + cross-platform termination helpers.
2
+
3
+ go-plugin escalates Kill() through: client.Close() (calls
4
+ ``GRPCController.Shutdown``) → wait 2s for graceful exit → SIGKILL via the
5
+ runner. We use the same shape, but split into discrete steps the client
6
+ can sequence.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import signal
12
+ import subprocess
13
+ import sys
14
+ from typing import Mapping, Sequence
15
+
16
+
17
+ def is_windows() -> bool:
18
+ return sys.platform.startswith("win")
19
+
20
+
21
+ def spawn(
22
+ cmd: Sequence[str],
23
+ *,
24
+ env: Mapping[str, str],
25
+ cwd: str | None = None,
26
+ ) -> subprocess.Popen[bytes]:
27
+ """Spawn a plugin subprocess. stdin → DEVNULL, stdout/stderr → pipes."""
28
+ creationflags = 0
29
+ start_new_session = False
30
+ if is_windows():
31
+ creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
32
+ else:
33
+ start_new_session = True # so SIGKILL won't take down the host
34
+
35
+ return subprocess.Popen(
36
+ list(cmd),
37
+ env=dict(env),
38
+ cwd=cwd,
39
+ stdin=subprocess.DEVNULL,
40
+ stdout=subprocess.PIPE,
41
+ stderr=subprocess.PIPE,
42
+ bufsize=0,
43
+ close_fds=True,
44
+ creationflags=creationflags,
45
+ start_new_session=start_new_session,
46
+ )
47
+
48
+
49
+ def terminate(p: subprocess.Popen) -> None:
50
+ """SIGTERM (or CTRL_BREAK on Windows). Safe to call after process exit."""
51
+ if p.poll() is not None:
52
+ return
53
+ if is_windows():
54
+ try:
55
+ p.send_signal(signal.CTRL_BREAK_EVENT) # type: ignore[attr-defined]
56
+ except (ValueError, OSError):
57
+ p.terminate()
58
+ else:
59
+ p.terminate()
60
+
61
+
62
+ def kill(p: subprocess.Popen) -> None:
63
+ """SIGKILL / TerminateProcess. Last resort."""
64
+ if p.poll() is not None:
65
+ return
66
+ p.kill()
@@ -0,0 +1,21 @@
1
+ // Vendored from hashicorp/go-plugin internal/plugin/grpc_broker.proto
2
+ // SPDX-License-Identifier: MPL-2.0
3
+ syntax = "proto3";
4
+ package plugin;
5
+ option go_package = "github.com/hashicorp/go-plugin/internal/plugin";
6
+
7
+ message ConnInfo {
8
+ uint32 service_id = 1;
9
+ string network = 2;
10
+ string address = 3;
11
+ message Knock {
12
+ bool knock = 1;
13
+ bool ack = 2;
14
+ string error = 3;
15
+ }
16
+ Knock knock = 4;
17
+ }
18
+
19
+ service GRPCBroker {
20
+ rpc StartStream(stream ConnInfo) returns (stream ConnInfo);
21
+ }
@@ -0,0 +1,12 @@
1
+ // Vendored from hashicorp/go-plugin internal/plugin/grpc_controller.proto
2
+ // SPDX-License-Identifier: MPL-2.0
3
+ syntax = "proto3";
4
+ package plugin;
5
+ option go_package = "github.com/hashicorp/go-plugin/internal/plugin";
6
+
7
+ message Empty {
8
+ }
9
+
10
+ service GRPCController {
11
+ rpc Shutdown(Empty) returns (Empty);
12
+ }
@@ -0,0 +1,22 @@
1
+ // Vendored from hashicorp/go-plugin internal/plugin/grpc_stdio.proto
2
+ // SPDX-License-Identifier: MPL-2.0
3
+ syntax = "proto3";
4
+ package plugin;
5
+ option go_package = "github.com/hashicorp/go-plugin/internal/plugin";
6
+
7
+ import "google/protobuf/empty.proto";
8
+
9
+ service GRPCStdio {
10
+ rpc StreamStdio(google.protobuf.Empty) returns (stream StdioData);
11
+ }
12
+
13
+ message StdioData {
14
+ enum Channel {
15
+ INVALID = 0;
16
+ STDOUT = 1;
17
+ STDERR = 2;
18
+ }
19
+
20
+ Channel channel = 1;
21
+ bytes data = 2;
22
+ }
pyplugin/reattach.py ADDED
@@ -0,0 +1,27 @@
1
+ """ReattachConfig — host re-connects to a pre-existing plugin process."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+ from .handshake import NETWORK_UNIX, PROTOCOL_GRPC
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ReattachConfig:
11
+ """Information needed to re-attach to a running plugin without spawning.
12
+
13
+ Mirrors go-plugin's ``ReattachConfig``. ``test=True`` (set by serving with
14
+ ``ServeConfig.test_mode``) tells the host's ``kill()`` to NOT terminate
15
+ the process — the test harness is responsible for shutdown.
16
+ """
17
+ pid: int
18
+ addr: str # unix path or "host:port"
19
+ network: str = NETWORK_UNIX
20
+ protocol: str = PROTOCOL_GRPC
21
+ protocol_version: int = 1
22
+ server_cert_pem: bytes | None = None # for AutoMTLS reattach
23
+ # If reattaching with AutoMTLS, the host must supply the *original* host
24
+ # client cert+key it generated for the plugin so the TLS handshake works.
25
+ client_cert_pem: bytes | None = None
26
+ client_key_pem: bytes | None = None
27
+ test: bool = False
pyplugin/server.py ADDED
@@ -0,0 +1,204 @@
1
+ """Plugin-side ``serve()`` entry point (grpclib async).
2
+
3
+ Mirrors go-plugin's ``Serve(opts *ServeConfig)`` lifecycle:
4
+
5
+ 1. Validate magic cookie (or skip in test mode).
6
+ 2. Negotiate protocol version against ``PLUGIN_PROTOCOL_VERSIONS``.
7
+ 3. Open a listener (unix on POSIX, TCP loopback on Windows or when forced).
8
+ 4. Build SSL context if ``PLUGIN_CLIENT_CERT`` is set (AutoMTLS).
9
+ 5. Collect servicers: GRPCBroker + GRPCController + GRPCStdio + Health
10
+ (service name = "plugin") + reflection + each user plugin.
11
+ 6. Build ``grpclib.server.Server`` and start it on the listener.
12
+ 7. Emit the handshake line to stdout, flush.
13
+ 8. Block until ``GRPCController.Shutdown`` fires or SIGTERM is received.
14
+
15
+ ``serve()`` is sync from the caller's perspective; it spins up its own
16
+ asyncio event loop. Plugin authors implement async servicers but call
17
+ ``serve(config)`` from a normal ``main()``.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import logging
23
+ import os
24
+ import signal
25
+ import ssl
26
+ import sys
27
+ from dataclasses import dataclass, field
28
+ from typing import Optional, Union
29
+
30
+ from grpclib.reflection.service import ServerReflection
31
+ from grpclib.server import Server
32
+
33
+ from . import mtls, transport
34
+ from .broker import TLSMaterial, make_server_side_broker
35
+ from .controller import GRPCControllerServicer
36
+ from .cookie import validate_or_exit
37
+ from .health import StaticHealth
38
+ from .handshake import (
39
+ HandshakeConfig,
40
+ PROTOCOL_GRPC,
41
+ format_line,
42
+ )
43
+ from .plugin import Plugin, PluginSet, VersionedPlugins
44
+ from .stdio import GRPCStdioServicer
45
+
46
+ GRPC_HEALTH_SERVICE_NAME = "plugin" # what the go-plugin host pings
47
+ ENV_CLIENT_CERT = "PLUGIN_CLIENT_CERT"
48
+ ENV_PROTOCOL_VERSIONS = "PLUGIN_PROTOCOL_VERSIONS"
49
+ ENV_MULTIPLEX_GRPC = "PLUGIN_MULTIPLEX_GRPC"
50
+
51
+
52
+ @dataclass
53
+ class ServeConfig:
54
+ """Configuration handed to :func:`serve`."""
55
+ handshake_config: HandshakeConfig
56
+ plugins: Union[PluginSet, VersionedPlugins]
57
+ logger: Optional[logging.Logger] = None
58
+ test_mode: bool = False
59
+ force_tcp: bool = False
60
+ grpc_options: list = field(default_factory=list)
61
+
62
+
63
+ def _is_versioned(p: Union[PluginSet, VersionedPlugins]) -> bool:
64
+ if not p:
65
+ return False
66
+ return all(isinstance(k, int) for k in p.keys())
67
+
68
+
69
+ def _negotiate_version(cfg: ServeConfig) -> tuple[int, PluginSet]:
70
+ """Pick (version, PluginSet) — mirrors go-plugin's protocolVersion()."""
71
+ versioned: dict[int, PluginSet] = {}
72
+ if _is_versioned(cfg.plugins):
73
+ versioned.update(cfg.plugins) # type: ignore[arg-type]
74
+ else:
75
+ versioned[cfg.handshake_config.protocol_version] = cfg.plugins # type: ignore[assignment]
76
+
77
+ client_versions: list[int] = []
78
+ raw = os.environ.get(ENV_PROTOCOL_VERSIONS, "")
79
+ for s in (p for p in raw.split(",") if p):
80
+ try:
81
+ client_versions.append(int(s))
82
+ except ValueError:
83
+ sys.stderr.write(f"server sent invalid plugin version {s!r}\n")
84
+
85
+ server_versions = sorted(versioned.keys(), reverse=True)
86
+ for v in server_versions:
87
+ if v in client_versions:
88
+ return v, versioned[v]
89
+
90
+ fallback = sorted(versioned.keys())[0]
91
+ return fallback, versioned[fallback]
92
+
93
+
94
+ async def _serve_async(config: ServeConfig) -> None:
95
+ logger = config.logger or logging.getLogger("pyplugin.server")
96
+
97
+ proto_version, plugin_set = _negotiate_version(config)
98
+ listener = transport.open_listener(force_tcp=config.force_tcp)
99
+
100
+ # AutoMTLS — match server.go: read PLUGIN_CLIENT_CERT, generate our cert,
101
+ # trust the host's cert as both client CA and root.
102
+ server_cert_b64 = ""
103
+ server_ssl: Optional[ssl.SSLContext] = None
104
+ server_cert: Optional[mtls.Cert] = None
105
+ client_cert_pem = os.environ.get(ENV_CLIENT_CERT)
106
+ if client_cert_pem:
107
+ server_cert = mtls.generate()
108
+ server_cert_b64 = mtls.encode_handshake_cert(server_cert.cert_der)
109
+ server_ssl = mtls.server_ssl_context(
110
+ cert_pem=server_cert.cert_pem,
111
+ key_pem=server_cert.key_pem,
112
+ peer_cert_pem=client_cert_pem.encode(),
113
+ )
114
+
115
+ # Build the broker's servicer + facade; the demux task we'll start under
116
+ # this same event loop.
117
+ broker_tls: Optional[TLSMaterial] = None
118
+ if server_cert is not None and client_cert_pem:
119
+ broker_tls = TLSMaterial(
120
+ cert_pem=server_cert.cert_pem,
121
+ key_pem=server_cert.key_pem,
122
+ peer_cert_pem=client_cert_pem.encode(),
123
+ )
124
+ broker_servicer, broker, demux_task = make_server_side_broker(broker_tls)
125
+
126
+ controller = GRPCControllerServicer()
127
+ stdio_servicer = GRPCStdioServicer()
128
+ health = StaticHealth()
129
+
130
+ user_servicers: list = []
131
+ for name, p in plugin_set.items():
132
+ if not isinstance(p, Plugin):
133
+ raise TypeError(f"plugin {name!r} must be a pyplugin.Plugin instance")
134
+ user_servicers.extend(p.servicers(broker))
135
+
136
+ base_servicers: list = [
137
+ broker_servicer,
138
+ controller,
139
+ stdio_servicer,
140
+ health,
141
+ ] + user_servicers
142
+
143
+ # Reflection extends the servicer list with its own service.
144
+ all_servicers = ServerReflection.extend(base_servicers)
145
+
146
+ server = Server(all_servicers)
147
+ if listener.network == "unix":
148
+ await server.start(path=listener.address, ssl=server_ssl)
149
+ else:
150
+ host, port = listener.address.split(":")
151
+ await server.start(host=host, port=int(port), ssl=server_ssl)
152
+
153
+ # Emit handshake. go-plugin always emits 6 segments; if PLUGIN_MULTIPLEX_GRPC
154
+ # is set, the 7th tells the host whether we *support* mux. We don't, so we
155
+ # advertise false (opt-out) — the host will then fail loudly if it required it.
156
+ multiplex: Optional[bool] = None
157
+ if os.environ.get(ENV_MULTIPLEX_GRPC):
158
+ multiplex = False
159
+ line = format_line(
160
+ app_protocol_version=proto_version,
161
+ network=listener.network,
162
+ address=listener.address,
163
+ protocol=PROTOCOL_GRPC,
164
+ server_cert=server_cert_b64,
165
+ multiplex_supported=multiplex,
166
+ )
167
+ if not config.test_mode:
168
+ sys.stdout.write(line + "\n")
169
+ sys.stdout.flush()
170
+
171
+ # Suppress SIGINT — go-plugin "eats" interrupts so the host owns shutdown.
172
+ # SIGTERM still triggers shutdown via the signal handler below.
173
+ loop = asyncio.get_running_loop()
174
+ if not config.test_mode:
175
+ try:
176
+ loop.add_signal_handler(signal.SIGINT, lambda: None)
177
+ loop.add_signal_handler(signal.SIGTERM, controller.shutdown_event.set)
178
+ except (NotImplementedError, RuntimeError):
179
+ pass # Windows / non-main thread — best effort
180
+
181
+ try:
182
+ await controller.shutdown_event.wait()
183
+ finally:
184
+ # GracefulStop the gRPC server, then unlink the unix socket.
185
+ server.close()
186
+ try:
187
+ await asyncio.wait_for(server.wait_closed(), timeout=2.0)
188
+ except asyncio.TimeoutError:
189
+ pass
190
+ await broker.close()
191
+ stdio_servicer.close()
192
+ demux_task.cancel()
193
+ if listener.cleanup_path and os.path.exists(listener.cleanup_path):
194
+ try:
195
+ os.remove(listener.cleanup_path)
196
+ except OSError:
197
+ pass
198
+
199
+
200
+ def serve(config: ServeConfig) -> None:
201
+ """Run the plugin server (sync entry point). Plugin's ``main()`` calls this."""
202
+ if not config.test_mode:
203
+ validate_or_exit(config.handshake_config)
204
+ asyncio.run(_serve_async(config))
pyplugin/stdio.py ADDED
@@ -0,0 +1,36 @@
1
+ """GRPCStdio servicer — streams plugin stdout/stderr writes to the host (grpclib async)."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+
6
+ from ._generated import grpc_stdio_grpc, grpc_stdio_pb2
7
+
8
+
9
+ class GRPCStdioServicer(grpc_stdio_grpc.GRPCStdioBase):
10
+ """Streams stdio chunks. ``StreamStdio`` is called once by the host."""
11
+
12
+ def __init__(self) -> None:
13
+ self._q: asyncio.Queue[grpc_stdio_pb2.StdioData | None] = asyncio.Queue()
14
+ self._consumed = asyncio.Event()
15
+
16
+ def write_stdout(self, data: bytes) -> None:
17
+ if data:
18
+ self._q.put_nowait(grpc_stdio_pb2.StdioData(
19
+ channel=grpc_stdio_pb2.StdioData.STDOUT, data=data))
20
+
21
+ def write_stderr(self, data: bytes) -> None:
22
+ if data:
23
+ self._q.put_nowait(grpc_stdio_pb2.StdioData(
24
+ channel=grpc_stdio_pb2.StdioData.STDERR, data=data))
25
+
26
+ def close(self) -> None:
27
+ self._q.put_nowait(None)
28
+
29
+ async def StreamStdio(self, stream) -> None: # noqa: N802
30
+ await stream.recv_message() # request is google.protobuf.Empty
31
+ self._consumed.set()
32
+ while True:
33
+ chunk = await self._q.get()
34
+ if chunk is None:
35
+ return
36
+ await stream.send_message(chunk)