feat: release v0.3.6

This commit is contained in:
chrishuan
2026-05-27 21:45:25 +08:00
parent 1bdcf28c5e
commit 438869bec8
48 changed files with 2484 additions and 459 deletions
@@ -126,6 +126,34 @@ def _resolve_gateway_host(default: str = _DEFAULT_GATEWAY_HOST) -> str:
return host or default
def _resolve_gateway_api_key() -> Optional[str]:
"""Read the optional Gateway Bearer token from the environment.
Looks at ``MEMORY_TENCENTDB_GATEWAY_API_KEY`` (Hermes-namespaced) first;
falls back to ``TDAI_GATEWAY_API_KEY`` so an operator who already wired
up the Gateway-side env var does not have to set two names. Returns
``None`` when neither is set, which means "do not attach an
Authorization header" — exactly matching the Gateway's own legacy
default. Whitespace-only values are treated as unset to guard against
shells that quote ``\\n`` into env vars.
Important: this is purely the **client-side** secret. Whether the
Gateway actually enforces a Bearer check is decided on the Gateway
side (its own ``TDAI_GATEWAY_API_KEY`` / ``server.apiKey``); the
plugin does not propagate this value across to the spawned Gateway.
The operator must configure the same secret on both ends if they
want auth enforcement.
"""
for var in ("MEMORY_TENCENTDB_GATEWAY_API_KEY", "TDAI_GATEWAY_API_KEY"):
raw = os.environ.get(var)
if raw is None:
continue
value = raw.strip()
if value:
return value
return None
# Candidate locations searched by _discover_gateway_cmd() when the user has not
# set MEMORY_TENCENTDB_GATEWAY_CMD. Order matters: in-tree checkout (next to
# this file) wins over ad-hoc clones in ``$HOME``.
@@ -675,7 +703,12 @@ class MemoryTencentdbProvider(MemoryProvider):
# registration and an exception would break the whole plugin).
host = _resolve_gateway_host()
port = _resolve_gateway_port()
client = MemoryTencentdbSdkClient(base_url=f"http://{host}:{port}", timeout=2)
api_key = _resolve_gateway_api_key()
client = MemoryTencentdbSdkClient(
base_url=f"http://{host}:{port}",
timeout=2,
api_key=api_key,
)
try:
result = client.health(timeout=2)
return result.get("status") in ("ok", "degraded")
@@ -711,11 +744,18 @@ class MemoryTencentdbProvider(MemoryProvider):
# it only runs when the env var is not set, so existing deployments
# are unaffected.
gateway_cmd = os.environ.get("MEMORY_TENCENTDB_GATEWAY_CMD") or _discover_gateway_cmd()
# Optional Bearer token attached to outbound Gateway requests
# (off by default). The plugin only handles the client side — if
# the operator wants the Gateway to enforce auth, they must
# configure ``TDAI_GATEWAY_API_KEY`` / ``server.apiKey`` on the
# Gateway side directly so both ends agree on the secret.
api_key = _resolve_gateway_api_key()
self._supervisor = GatewaySupervisor(
host=host,
port=port,
gateway_cmd=gateway_cmd,
api_key=api_key,
)
# Mark as initialized immediately so tools are registered
@@ -1045,6 +1085,20 @@ class MemoryTencentdbProvider(MemoryProvider):
"default": "8420",
"env_var": "MEMORY_TENCENTDB_GATEWAY_PORT",
},
{
"key": "gateway_api_key",
"description": (
"Optional Bearer token attached to outbound Gateway "
"requests. Set this to the same secret you configure on "
"the Gateway side (``TDAI_GATEWAY_API_KEY`` / "
"``server.apiKey``) so the Bearer comparison succeeds. "
"Leave unset to skip the Authorization header entirely "
"(legacy default; matches an open Gateway)."
),
"secret": True,
"required": False,
"env_var": "MEMORY_TENCENTDB_GATEWAY_API_KEY",
},
{
"key": "llm_api_key",
"description": "LLM API key (for Gateway's standalone LLM calls)",
@@ -20,9 +20,51 @@ DEFAULT_TIMEOUT = 10 # seconds
class MemoryTencentdbSdkClient:
"""HTTP client for the memory-tencentdb Gateway sidecar."""
def __init__(self, base_url: str = "http://127.0.0.1:8420", timeout: int = DEFAULT_TIMEOUT):
def __init__(
self,
base_url: str = "http://127.0.0.1:8420",
timeout: int = DEFAULT_TIMEOUT,
api_key: Optional[str] = None,
):
"""Construct the client.
Args:
base_url: Gateway base URL.
timeout: Default request timeout in seconds.
api_key: Optional Bearer token. When non-empty, every request
attaches ``Authorization: Bearer <api_key>``. When ``None``
or empty, no auth header is sent — this preserves the
pre-existing open-Gateway behaviour and is the right default
for any deployment where the Gateway has not opted into
``TDAI_GATEWAY_API_KEY`` yet.
The provider sources this value from
``MEMORY_TENCENTDB_GATEWAY_API_KEY`` (with
``TDAI_GATEWAY_API_KEY`` as a fallback). The Gateway must
be configured with the matching secret independently —
this client does not (and should not) propagate the value
across to the Gateway process.
"""
self._base_url = base_url.rstrip("/")
self._timeout = timeout
# Strip whitespace defensively — env vars often pick up trailing
# newlines from `echo` or YAML quoting; an exact-match Bearer
# comparison would otherwise reject a key that "looks right".
self._api_key = (api_key or "").strip() or None
def _build_headers(self, *, content_type: bool) -> Dict[str, str]:
"""Build request headers, conditionally adding Authorization.
Centralised so the auth header logic is stated once: every method
below goes through ``_post`` / ``_get`` which call this helper. If
you ever add a new HTTP verb, route it here.
"""
headers: Dict[str, str] = {}
if content_type:
headers["Content-Type"] = "application/json"
if self._api_key:
headers["Authorization"] = f"Bearer {self._api_key}"
return headers
def _post(self, path: str, body: Dict[str, Any], timeout: Optional[int] = None) -> Dict[str, Any]:
"""Make a POST request to the Gateway."""
@@ -31,7 +73,7 @@ class MemoryTencentdbSdkClient:
req = urllib.request.Request(
url,
data=data,
headers={"Content-Type": "application/json"},
headers=self._build_headers(content_type=True),
method="POST",
)
try:
@@ -52,7 +94,11 @@ class MemoryTencentdbSdkClient:
def _get(self, path: str, timeout: Optional[int] = None) -> Dict[str, Any]:
"""Make a GET request to the Gateway."""
url = f"{self._base_url}{path}"
req = urllib.request.Request(url, method="GET")
req = urllib.request.Request(
url,
headers=self._build_headers(content_type=False),
method="GET",
)
try:
with urllib.request.urlopen(req, timeout=timeout or self._timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
@@ -40,11 +40,35 @@ class GatewaySupervisor:
host: str = DEFAULT_HOST,
port: int = DEFAULT_PORT,
gateway_cmd: Optional[str] = None,
api_key: Optional[str] = None,
):
"""Construct the supervisor.
Args:
host: Gateway bind host.
port: Gateway bind port.
gateway_cmd: Shell command to spawn the Gateway. Falls back to
``MEMORY_TENCENTDB_GATEWAY_CMD`` env var when None.
api_key: Optional Gateway Bearer token used by the **client**
(every outbound request adds ``Authorization: Bearer <key>``).
The supervisor does NOT propagate this value to the spawned
Gateway's environment — turning auth on at the Gateway is the
operator's responsibility (set ``TDAI_GATEWAY_API_KEY`` /
``server.apiKey`` on the Gateway side directly, in the same
place you'd configure its port and data dir). Both ends must
see the same secret; the plugin only handles the client half.
``None`` / empty means "do not attach an Authorization
header", which preserves the legacy default.
"""
self._host = host
self._port = port
self._base_url = f"http://{host}:{port}"
self._client = MemoryTencentdbSdkClient(base_url=self._base_url, timeout=5)
self._api_key = (api_key or "").strip() or None
self._client = MemoryTencentdbSdkClient(
base_url=self._base_url,
timeout=5,
api_key=self._api_key,
)
self._process: Optional[subprocess.Popen] = None
# File handles for child's stdout/stderr. Kept open for the lifetime of
# the process so the kernel pipe buffer never fills up (otherwise the
@@ -144,6 +168,12 @@ class GatewaySupervisor:
env = os.environ.copy()
env["MEMORY_TENCENTDB_GATEWAY_PORT"] = str(self._port)
env["MEMORY_TENCENTDB_GATEWAY_HOST"] = self._host
# Note: we deliberately do NOT inject TDAI_GATEWAY_API_KEY into
# the child's env from here. Whether the Gateway enforces auth is
# the operator's call — they configure it on the Gateway side
# (env, yaml, docker run, systemd unit) just like any other
# Gateway setting. The supervisor's ``api_key`` is purely the
# client-side Bearer token used for outbound requests.
# Redirect child stdout/stderr to log files instead of PIPE.
# Using PIPE without an active reader will deadlock the child once