Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/brainsurfing-tech_swarph-mesh.json
T
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
167 KiB
JSON

{"repo": "brainsurfing-tech/swarph-mesh", "files": {"src/swarph_mesh/mesh_client.py": {"0d9531487038ff99068caeac1178d2ebeb0e08cf": {"t": 1785747481.874702, "n": 19090, "h": "a43ffdc568899c6b", "c": "\"\"\"``MeshClient`` — async wrapper around the mesh-gateway HTTP API per\nPLAN.md §5.\n\nReplaces the hand-rolled curl-based code in:\n\n* an inbox-drain script\n* assorted ad-hoc curl invocations in session ritual\n\nwith a single typed surface so cross-Claude DM coordination doesn't\nrequire re-discovering the gateway shape from scratch every time.\n\nPublic surface:\n\n client = MeshClient(node=\"my-cell\", token=os.environ[\"MESH_GATEWAY_TOKEN\"])\n peers = await client.list_peers()\n msgs = await client.fetch(unread_only=True)\n sent = await client.send(to=\"other-cell\", kind=\"fyi\", content=\"...\")\n await client.mark_read(msg_id=123)\n await client.register(url=\"http://localhost:8787\", capabilities={...})\n\nTwo structural invariants enforced:\n\n1. **Recipient name validation.** Every ``send()`` calls\n ``swarph_shared.validate_node_name`` on the recipient — closes the\n Vector A (peer-onboarding chatter) + Vector B (human-prompt\n shorthand) framing-contagion classes documented in\n ``project_peer_name_canonical.md``.\n\n2. **Mesh-secrets out-of-band guard.** Every ``send()`` runs a\n best-effort regex sniff on content for obvious credentials\n (``pypi-...``, ``sk-...``, ``gh[ops]_...``, ``eyJ...`` JWTs).\n Hits raise :class:`MeshSecretLeakError` BEFORE the POST. CLAUDE.md\n \"Mesh secrets out-of-band only — NEVER ride /messages content\n fields\" is the non-negotiable rule. Best-effort because regex\n misses are common — operators must still treat content fields as\n public. The guard catches the obvious cases.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport re\nfrom typing import Any, Literal, Optional\n\n\n# v0.5.1 (drop DM #722 + #728): client-side enum for mesh-gateway-accepted\n# DM kinds. Gateway validates server-side too; this is fail-fast at the\n# type-check + runtime layer so callers don't round-trip a known-bad value\n# (e.g., kind=\"review_request\" → 400). Same shape as the validate_caller\n# pattern from swarph_shared. Add new kinds here when the gateway ships\n# them (current accepted set per server.py mesh-gateway):\nDM_KIND = Literal[\"fyi\", \"question\", \"answer\", \"status\", \"unblock\"]\n\nimport httpx\nfrom swarph_shared import validate_node_name\n\nfrom swarph_mesh.exceptions import SwarphMeshError\nfrom swarph_mesh.mesh_types import MeshMessage, MeshPeer\n\n\nDEFAULT_GATEWAY_URL = \"http://localhost:8788\"\nGATEWAY_TOKEN_ENV = \"MESH_GATEWAY_TOKEN\"\nDEFAULT_TIMEOUT_SECONDS = 10.0\n\n\n# ---------------------------------------------------------------------------\n# Exceptions\n# ---------------------------------------------------------------------------\n\n\nclass MeshGatewayError(SwarphMeshError):\n \"\"\"Gateway returned non-2xx or otherwise non-conformant response.\"\"\"\n\n\nclass MeshAuthError(MeshGatewayError):\n \"\"\"Authentication failed — token missing or rejected (401/403).\"\"\"\n\n\nclass MeshSecretLeakError(SwarphMeshError):\n \"\"\"Best-effort guard caught an apparent credential in DM content.\n\n Refuses to send. Operators must rotate the credential and resend\n without it.\n \"\"\"\n\n\n# ---------------------------------------------------------------------------\n# Best-effort credential leak detector\n# ---------------------------------------------------------------------------\n\n# Patterns chosen to match observable shapes of credentials seen in\n# session JSONLs in the field. Updates here are cheap; misses are\n# expected (defense-in-depth, not strict validation).\n_CRED_PATTERNS: list[tuple[str, re.Pattern[str]]] = [\n (\"pypi token\", re.compile(r\"\\bpypi-[A-Za-z0-9_-]{20,}\")),\n (\"anthropic api key\", re.compile(r\"\\bsk-ant-[A-Za-z0-9_-]{20,}\")),\n # OpenAI ADMIN keys (highest privilege class — can create/delete keys,\n # manage org). Listed FIRST so it matches before the generic openai\n # pattern, giving the operator a properly scary error message.\n # Endpoint that mints these: POST /v1/organization/admin_api_keys.\n (\"openai ADMIN key (org-level privilege)\", re.compile(r\"\\bsk-admin-[A-Za-z0-9_-]{20,}\")),\n # OpenAI key shapes: legacy ``sk-<48hex>`` + project keys\n # ``sk-proj-<long>`` (newer, longer alphabet incl. ``_-``). Earlier\n # regex ``sk-[A-Za-z0-9]{20,}`` missed sk-proj-... because of the\n # underscore + hyphen in the body. Issue #4 tightening.\n # NOTE: ``sk-admin-...`` is matched by the named pattern above so\n # this catch-all doesn't downgrade an admin key's error severity.\n (\"openai api key\", re.compile(r\"\\bsk-(?!admin-)(?:proj-)?[A-Za-z0-9_-]{20,}\")),\n # xAI / Grok key — xai-<long>. Same shape as OpenAI's hyphen-prefix\n # pattern. Added in v0.5.1 alongside the Grok adapter shipped in\n # v0.5.0; would have matched the key the user pasted in chat earlier\n # this session if mesh send had been the destination.\n (\"xai api key\", re.compile(r\"\\bxai-[A-Za-z0-9_-]{40,}\")),\n # DeepSeek api key — sk-<32hex> hex-only post the sk- prefix.\n # Already covered loosely by the openai pattern but listing\n # explicitly so the error message names the right provider.\n (\"deepseek api key\", re.compile(r\"\\bsk-[a-f0-9]{32}\\b\")),\n # Google Gemini / Cloud API keys — ``AIza<35-alnum>`` format.\n # Common shape across Google's API tier; would catch GEMINI_API_KEY\n # leaks. Issue #4 tightening (was missing).\n (\"google api key\", re.compile(r\"\\bAIza[0-9A-Za-z_-]{35}\")),\n (\"github token\", re.compile(r\"\\bgh[ops]_[A-Za-z0-9]{30,}\")),\n (\"aws access key\", re.compile(r\"\\bAKIA[0-9A-Z]{16}\\b\")),\n (\"aws secret key\", re.compile(r\"\\b[A-Za-z0-9/+=]{40}\\b(?=.*aws)\")), # heuristic\n # JWT — three base64url segments separated by dots (stricter than the\n # generic shape so we don't false-fire on every blob with two dots).\n (\"jwt\", re.compile(r\"\\beyJ[A-Za-z0-9_-]{15,}\\.eyJ[A-Za-z0-9_-]{15,}\\.[A-Za-z0-9_-]{15,}\\b\")),\n # Mesh gateway tokens — long random base64url-shaped values stored in\n # MESH_GATEWAY_TOKEN env. Heuristic shape: 40+ chars of url-safe\n # base64 alphabet. False-positive rate higher than other patterns —\n # operator escape via skip_secret_check=True is the right hatch.\n (\"possible mesh-gateway token\", re.compile(r\"\\b[A-Za-z0-9_-]{48,}\\b(?=.*MESH_GATEWAY_TOKEN)\")),\n]\n\n\ndef _check_no_secret(content: str) -> None:\n \"\"\"Raise :class:`MeshSecretLeakError` if content looks like it\n carries a credential. Best-effort; expected to miss novel shapes.\n \"\"\"\n # Exact-match the LIVE gateway token value. The regex pattern below uses a\n # `(?=.*MESH_GATEWAY_TOKEN)` lookahead, so it only fires when the literal\n # string 'MESH_GATEWAY_TOKEN' co-occurs — a bare token PASTE (the common\n # leak) sailed through. Comparing against the actual env value is exact\n # (zero false positives) and catches that case. (adversarial-sweep MED.)\n env_tok = os.environ.get(GATEWAY_TOKEN_ENV, \"\").strip()\n if env_tok and len(env_tok) >= 16 and env_tok in content:\n raise MeshSecretLeakError(\n f\"refusing to send: content contains the live {GATEWAY_TOKEN_ENV} \"\n \"value. Mesh secrets out-of-band only — NEVER ride /messages content \"\n \"fields. (CLAUDE.md mesh-secrets rule.)\"\n )\n for label, rx in _CRED_PATTERNS:\n if rx.search(content):\n raise MeshSecretLeakError(\n f\"refusing to send: content matches {label} pattern. \"\n \"Mesh secrets out-of-band only — NEVER ride /messages \"\n \"content fields. Use ssh+tee, commander manual paste, \"\n \"or pre-redacted Hub records. (CLAUDE.md mesh-secrets rule.)\"\n )\n\n\n# ---------------------------------------------------------------------------\n# MeshClient\n# ---------------------------------------------------------------------------\n\n\nclass MeshClient:\n \"\"\"Async client for the mesh-gateway HTTP API.\n\n Construct once per peer (or per cycle), reuse for the lifetime of\n the work. Backed by ``httpx.AsyncClient`` so connection-pooling\n happens for free across many ``send()`` calls.\n\n Use as an async context manager when you want explicit close:\n\n async with MeshClient(node=\"my-cell\") as client:\n await client.send(to=\"other-cell\", kind=\"fyi\", content=\"...\")\n\n Or instantiate normally + call ``aclose()`` when done.\n \"\"\"\n\n def __init__(\n self,\n node: str,\n *,\n token: Optional[str] = None,\n gateway_url: Optional[str] = None,\n timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,\n validate_self_name: bool = True,\n ):\n \"\"\"``token`` falls back to ``MESH_GATEWAY_TOKEN`` env. ``gateway_url``\n falls back to ``MESH_GATEWAY_URL`` env, then ``http://localhost:8788``.\n\n Set ``validate_self_name=False`` for test fixtures that need to\n instantiate a client with a mock peer name. Production callers\n should leave it on (caller's own ``node`` is the most common\n contagion target — see project_peer_name_canonical.md).\n \"\"\"\n # Self-name validation — best-effort. If the gateway is offline\n # we still want construction to succeed; the validation is\n # registry-dependent so use strict=False to skip the registry\n # check when offline.\n if validate_self_name:\n try:\n node = validate_node_name(node, strict=False)\n except Exception: # pragma: no cover — defensive\n pass\n\n self.node = node\n self._token = token if token is not None else os.environ.get(GATEWAY_TOKEN_ENV)\n self._gateway_url = (\n gateway_url\n or os.environ.get(\"MESH_GATEWAY_URL\")\n or DEFAULT_GATEWAY_URL\n )\n self._timeout = timeout_seconds\n self._client: Optional[httpx.AsyncClient] = None\n\n # ------------------------------------------------------------------\n # Lifecycle\n # ------------------------------------------------------------------\n\n def _ensure_client(self) -> httpx.AsyncClient:\n if self._client is None or self._client.is_closed:\n headers: dict[str, str] = {}\n if self._token:\n headers[\"Authorization\"] = f\"Bearer {self._token}\"\n self._client = httpx.AsyncClient(\n base_url=self._gateway_url,\n headers=headers,\n timeout=self._timeout,\n )\n return self._client\n\n async def aclose(self) -> None:\n if self._client is not None and not self._client.is_closed:\n await self._client.aclose()\n self._client = None\n\n async def __aenter__(self) -> \"MeshClient\":\n self._ensure_client()\n return self\n\n async def __aexit__(self, exc_type, exc, tb) -> None:\n await self.aclose()\n\n # ------------------------------------------------------------------\n # Internal HTTP wrapper\n # ------------------------------------------------------------------\n\n async def _request(\n self,\n method: str,\n path: str,\n *,\n params: Optional[dict[str, Any]] = None,\n json: Optional[dict[str, Any]] = None,\n ) -> Any:\n client = self._ensure_client()\n try:\n resp = await client.request(method, path, params=params, json=json)\n except httpx.RequestError as exc:\n raise MeshGatewayError(\n f\"mesh-gateway {method} {path} request failed: {exc}\"\n ) from exc\n\n if resp.status_code == 401 or resp.status_code == 403:\n raise MeshAuthError(\n f\"mesh-gateway {method} {path} returned {resp.status_code}: \"\n f\"check MESH_GATEWAY_TOKEN env\"\n )\n if resp.status_code >= 400:\n body = resp.text[:500]\n raise MeshGatewayError(\n f\"mesh-gateway {method} {path} returned {resp.status_code}: {body}\"\n )\n\n # Gateway returns JSON for everything except /health\n if not resp.content:\n return None\n try:\n return resp.json()\n except ValueError as exc:\n raise MeshGatewayError(\n f\"mesh-gateway {method} {path} returned non-JSON: {resp.text[:200]}\"\n ) from exc\n\n # ------------------------------------------------------------------\n # Peer endpoints\n # ------------------------------------------------------------------\n\n async def list_peers(self) -> list[MeshPeer]:\n \"\"\"``GET /peers`` — return all registered peers.\"\"\"\n payload = await self._request(\"GET\", \"/peers\")\n rows = payload.get(\"peers\", payload) if isinstance(payload, dict) else payload\n return [MeshPeer.model_validate(r) for r in rows]\n\n async def register(\n self,\n *,\n url: Optional[str] = None,\n capabilities: Optional[dict[str, Any]] = None,\n ) -> MeshPeer:\n \"\"\"``POST /peers/register`` — register this peer with the gateway.\"\"\"\n body: dict[str, Any] = {\"name\": self.node}\n if url is not None:\n body[\"url\"] = url\n if capabilities is not None:\n body[\"capabilities\"] = capabilities\n payload = await self._request(\"POST\", \"/peers/register\", json=body)\n return MeshPeer.model_validate(payload)\n\n # ------------------------------------------------------------------\n # Message endpoints\n # ------------------------------------------------------------------\n\n async def fetch(\n self,\n *,\n to_node: Optional[str] = None,\n unread_only: bool = False,\n limit: Optional[int] = None,\n ) -> list[MeshMessage]:\n \"\"\"``GET /messages`` — fetch own inbox.\n\n Defaults ``to_node`` to ``self.node``. Pass an explicit value\n only if you have a legitimate reason to peek at another peer's\n inbox (gateway access-controls this; most callers will get\n their own inbox only).\n\n v0.5.1 latent bug fix (discovered in Phase 5.6 daemon build):\n the mesh-gateway query parameter is ``?to=`` — NOT ``?to_node=``.\n The latter is silently ignored, returning ALL recent messages\n regardless of recipient. Pre-fix every ``MeshClient.fetch``\n caller saw outbound bleed-through unless they Python-side\n filtered. The kwarg keeps its descriptive name for the\n caller-facing API; only the wire-shape changes.\n \"\"\"\n # Defense-in-depth: filter from_node!=self_node client-side too,\n # so any future gateway quirk that re-introduces outbound\n # bleed-through doesn't silently break consumers (same shape\n # as the daemon's filter).\n target = to_node or self.node\n params: dict[str, Any] = {\"to\": target}\n if unread_only:\n params[\"unread_only\"] = \"true\"\n if limit is not None:\n params[\"limit\"] = limit\n payload = await self._request(\"GET\", \"/messages\", params=params)\n rows = payload.get(\"messages\", payload) if isinstance(payload, dict) else payload\n # Client-side defensive filter — only if fetching own inbox\n # (i.e., target == self.node). When peeking at another peer's\n # inbox via explicit to_node= override, return whatever gateway\n # returns without filtering (caller asked for raw view).\n if target == self.node:\n rows = [r for r in rows if r.get(\"from_node\") != self.node]\n return [MeshMessage.model_validate(r) for r in rows]\n\n async def send(\n self,\n *,\n to: str,\n kind: DM_KIND,\n content: str,\n related_task_id: Optional[str] = None,\n thread_id: Optional[str] = None,\n skip_secret_check: bool = False,\n ) -> MeshMessage:\n \"\"\"``POST /messages`` — send a DM.\n\n Validates ``to`` via ``swarph_shared.validate_node_name`` (closes\n Vector A + B contagion classes); raises ``ValueError`` /\n ``NotInRegistry`` on invalid recipient names BEFORE the POST.\n\n ``kind`` is a ``Literal`` enum (see ``DM_KIND`` above) — type\n checkers reject unknown values at lint time, and the runtime\n guard below catches anyone passing ``kind`` as ``str``-typed\n from a callsite that bypassed type-checking.\n\n Runs the best-effort credential leak detector on ``content``.\n Set ``skip_secret_check=True`` only when content has been\n inspected by the caller and the leak detector is producing a\n false positive (e.g. discussing a credential format in\n prose). False positives should be rare; treat them as a\n signal that the content is on a fence and rotate aggressively\n if you're not 100% sure.\n \"\"\"\n # Runtime kind enum check — defense-in-depth against str-typed\n # callsites that slipped past type-checking. Same shape as\n # validate_caller fail-fast discipline.\n if kind not in {\"fyi\", \"question\", \"answer\", \"status\", \"unblock\"}:\n raise ValueError(\n f\"MeshClient.send: kind={kind!r} is not a valid mesh-gateway \"\n f\"DM kind. Accepted: fyi / question / answer / status / unblock. \"\n f\"(Adding new kinds requires gateway-side support; check \"\n f\"server.py POST /messages handler.)\"\n )\n\n # Recipient validation — FAIL-CLOSED (strict=True). A send is about to\n # hit the same gateway via POST /messages, so the registry's /peers is\n # reachable in the normal case (and a warm TTL cache covers transient\n # blips). strict=False used to fail OPEN: a /peers hiccup skipped the\n # registry check and let a regex-valid-but-unregistered name through,\n # POSTing the DM into the void (adversarial-sweep auth-bypass HIGH).\n # Pass this client's own gateway_url + token so the registry check hits\n # the SAME gateway we POST to (not the MESH_GATEWAY_URL env default).\n canonical_to = validate_node_name(\n to, strict=True, gateway_url=self._gateway_url, token=self._token,\n )\n\n # Best-effort credential leak guard. Operator can disable for\n # legitimate prose mentioning credential shapes.\n if not skip_secret_check:\n _check_no_secret(content)\n\n body: dict[str, Any] = {\n \"from_node\": self.node,\n \"to_node\": canonical_to,\n \"kind\": kind,\n \"content\": content,\n }\n if related_task_id is not None:\n body[\"related_task_id\"] = related_task_id\n if thread_id is not None:\n body[\"thread_id\"] = thread_id\n\n payload = await self._request(\"POST\", \"/messages\", json=body)\n return MeshMessage.model_validate(payload)\n\n async def mark_read(self, msg_id: int) -> None:\n \"\"\"``POST /messages/{msg_id}/read`` — flip ``read_at`` on a DM.\n\n Per the mesh DM drain convention, the cron-side\n watcher does NOT mark read; only the commander or the\n peer-in-session does. Adapter callers consuming inboxes\n should mark-read explicitly when they've actually processed\n the DM, not on fetch.\n \"\"\"\n await self._request(\"POST\", f\"/messages/{msg_id}/read\")\n"}, "0bf694e56cfd9010a920264c1cb10c6cb523565f": {"t": 1785773541.6338077, "n": 19090, "h": "a43ffdc568899c6b", "c": "\"\"\"``MeshClient`` — async wrapper around the mesh-gateway HTTP API per\nPLAN.md §5.\n\nReplaces the hand-rolled curl-based code in:\n\n* an inbox-drain script\n* assorted ad-hoc curl invocations in session ritual\n\nwith a single typed surface so cross-Claude DM coordination doesn't\nrequire re-discovering the gateway shape from scratch every time.\n\nPublic surface:\n\n client = MeshClient(node=\"my-cell\", token=os.environ[\"MESH_GATEWAY_TOKEN\"])\n peers = await client.list_peers()\n msgs = await client.fetch(unread_only=True)\n sent = await client.send(to=\"other-cell\", kind=\"fyi\", content=\"...\")\n await client.mark_read(msg_id=123)\n await client.register(url=\"http://localhost:8787\", capabilities={...})\n\nTwo structural invariants enforced:\n\n1. **Recipient name validation.** Every ``send()`` calls\n ``swarph_shared.validate_node_name`` on the recipient — closes the\n Vector A (peer-onboarding chatter) + Vector B (human-prompt\n shorthand) framing-contagion classes documented in\n ``project_peer_name_canonical.md``.\n\n2. **Mesh-secrets out-of-band guard.** Every ``send()`` runs a\n best-effort regex sniff on content for obvious credentials\n (``pypi-...``, ``sk-...``, ``gh[ops]_...``, ``eyJ...`` JWTs).\n Hits raise :class:`MeshSecretLeakError` BEFORE the POST. CLAUDE.md\n \"Mesh secrets out-of-band only — NEVER ride /messages content\n fields\" is the non-negotiable rule. Best-effort because regex\n misses are common — operators must still treat content fields as\n public. The guard catches the obvious cases.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport re\nfrom typing import Any, Literal, Optional\n\n\n# v0.5.1 (drop DM #722 + #728): client-side enum for mesh-gateway-accepted\n# DM kinds. Gateway validates server-side too; this is fail-fast at the\n# type-check + runtime layer so callers don't round-trip a known-bad value\n# (e.g., kind=\"review_request\" → 400). Same shape as the validate_caller\n# pattern from swarph_shared. Add new kinds here when the gateway ships\n# them (current accepted set per server.py mesh-gateway):\nDM_KIND = Literal[\"fyi\", \"question\", \"answer\", \"status\", \"unblock\"]\n\nimport httpx\nfrom swarph_shared import validate_node_name\n\nfrom swarph_mesh.exceptions import SwarphMeshError\nfrom swarph_mesh.mesh_types import MeshMessage, MeshPeer\n\n\nDEFAULT_GATEWAY_URL = \"http://localhost:8788\"\nGATEWAY_TOKEN_ENV = \"MESH_GATEWAY_TOKEN\"\nDEFAULT_TIMEOUT_SECONDS = 10.0\n\n\n# ---------------------------------------------------------------------------\n# Exceptions\n# ---------------------------------------------------------------------------\n\n\nclass MeshGatewayError(SwarphMeshError):\n \"\"\"Gateway returned non-2xx or otherwise non-conformant response.\"\"\"\n\n\nclass MeshAuthError(MeshGatewayError):\n \"\"\"Authentication failed — token missing or rejected (401/403).\"\"\"\n\n\nclass MeshSecretLeakError(SwarphMeshError):\n \"\"\"Best-effort guard caught an apparent credential in DM content.\n\n Refuses to send. Operators must rotate the credential and resend\n without it.\n \"\"\"\n\n\n# ---------------------------------------------------------------------------\n# Best-effort credential leak detector\n# ---------------------------------------------------------------------------\n\n# Patterns chosen to match observable shapes of credentials seen in\n# session JSONLs in the field. Updates here are cheap; misses are\n# expected (defense-in-depth, not strict validation).\n_CRED_PATTERNS: list[tuple[str, re.Pattern[str]]] = [\n (\"pypi token\", re.compile(r\"\\bpypi-[A-Za-z0-9_-]{20,}\")),\n (\"anthropic api key\", re.compile(r\"\\bsk-ant-[A-Za-z0-9_-]{20,}\")),\n # OpenAI ADMIN keys (highest privilege class — can create/delete keys,\n # manage org). Listed FIRST so it matches before the generic openai\n # pattern, giving the operator a properly scary error message.\n # Endpoint that mints these: POST /v1/organization/admin_api_keys.\n (\"openai ADMIN key (org-level privilege)\", re.compile(r\"\\bsk-admin-[A-Za-z0-9_-]{20,}\")),\n # OpenAI key shapes: legacy ``sk-<48hex>`` + project keys\n # ``sk-proj-<long>`` (newer, longer alphabet incl. ``_-``). Earlier\n # regex ``sk-[A-Za-z0-9]{20,}`` missed sk-proj-... because of the\n # underscore + hyphen in the body. Issue #4 tightening.\n # NOTE: ``sk-admin-...`` is matched by the named pattern above so\n # this catch-all doesn't downgrade an admin key's error severity.\n (\"openai api key\", re.compile(r\"\\bsk-(?!admin-)(?:proj-)?[A-Za-z0-9_-]{20,}\")),\n # xAI / Grok key — xai-<long>. Same shape as OpenAI's hyphen-prefix\n # pattern. Added in v0.5.1 alongside the Grok adapter shipped in\n # v0.5.0; would have matched the key the user pasted in chat earlier\n # this session if mesh send had been the destination.\n (\"xai api key\", re.compile(r\"\\bxai-[A-Za-z0-9_-]{40,}\")),\n # DeepSeek api key — sk-<32hex> hex-only post the sk- prefix.\n # Already covered loosely by the openai pattern but listing\n # explicitly so the error message names the right provider.\n (\"deepseek api key\", re.compile(r\"\\bsk-[a-f0-9]{32}\\b\")),\n # Google Gemini / Cloud API keys — ``AIza<35-alnum>`` format.\n # Common shape across Google's API tier; would catch GEMINI_API_KEY\n # leaks. Issue #4 tightening (was missing).\n (\"google api key\", re.compile(r\"\\bAIza[0-9A-Za-z_-]{35}\")),\n (\"github token\", re.compile(r\"\\bgh[ops]_[A-Za-z0-9]{30,}\")),\n (\"aws access key\", re.compile(r\"\\bAKIA[0-9A-Z]{16}\\b\")),\n (\"aws secret key\", re.compile(r\"\\b[A-Za-z0-9/+=]{40}\\b(?=.*aws)\")), # heuristic\n # JWT — three base64url segments separated by dots (stricter than the\n # generic shape so we don't false-fire on every blob with two dots).\n (\"jwt\", re.compile(r\"\\beyJ[A-Za-z0-9_-]{15,}\\.eyJ[A-Za-z0-9_-]{15,}\\.[A-Za-z0-9_-]{15,}\\b\")),\n # Mesh gateway tokens — long random base64url-shaped values stored in\n # MESH_GATEWAY_TOKEN env. Heuristic shape: 40+ chars of url-safe\n # base64 alphabet. False-positive rate higher than other patterns —\n # operator escape via skip_secret_check=True is the right hatch.\n (\"possible mesh-gateway token\", re.compile(r\"\\b[A-Za-z0-9_-]{48,}\\b(?=.*MESH_GATEWAY_TOKEN)\")),\n]\n\n\ndef _check_no_secret(content: str) -> None:\n \"\"\"Raise :class:`MeshSecretLeakError` if content looks like it\n carries a credential. Best-effort; expected to miss novel shapes.\n \"\"\"\n # Exact-match the LIVE gateway token value. The regex pattern below uses a\n # `(?=.*MESH_GATEWAY_TOKEN)` lookahead, so it only fires when the literal\n # string 'MESH_GATEWAY_TOKEN' co-occurs — a bare token PASTE (the common\n # leak) sailed through. Comparing against the actual env value is exact\n # (zero false positives) and catches that case. (adversarial-sweep MED.)\n env_tok = os.environ.get(GATEWAY_TOKEN_ENV, \"\").strip()\n if env_tok and len(env_tok) >= 16 and env_tok in content:\n raise MeshSecretLeakError(\n f\"refusing to send: content contains the live {GATEWAY_TOKEN_ENV} \"\n \"value. Mesh secrets out-of-band only — NEVER ride /messages content \"\n \"fields. (CLAUDE.md mesh-secrets rule.)\"\n )\n for label, rx in _CRED_PATTERNS:\n if rx.search(content):\n raise MeshSecretLeakError(\n f\"refusing to send: content matches {label} pattern. \"\n \"Mesh secrets out-of-band only — NEVER ride /messages \"\n \"content fields. Use ssh+tee, commander manual paste, \"\n \"or pre-redacted Hub records. (CLAUDE.md mesh-secrets rule.)\"\n )\n\n\n# ---------------------------------------------------------------------------\n# MeshClient\n# ---------------------------------------------------------------------------\n\n\nclass MeshClient:\n \"\"\"Async client for the mesh-gateway HTTP API.\n\n Construct once per peer (or per cycle), reuse for the lifetime of\n the work. Backed by ``httpx.AsyncClient`` so connection-pooling\n happens for free across many ``send()`` calls.\n\n Use as an async context manager when you want explicit close:\n\n async with MeshClient(node=\"my-cell\") as client:\n await client.send(to=\"other-cell\", kind=\"fyi\", content=\"...\")\n\n Or instantiate normally + call ``aclose()`` when done.\n \"\"\"\n\n def __init__(\n self,\n node: str,\n *,\n token: Optional[str] = None,\n gateway_url: Optional[str] = None,\n timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,\n validate_self_name: bool = True,\n ):\n \"\"\"``token`` falls back to ``MESH_GATEWAY_TOKEN`` env. ``gateway_url``\n falls back to ``MESH_GATEWAY_URL`` env, then ``http://localhost:8788``.\n\n Set ``validate_self_name=False`` for test fixtures that need to\n instantiate a client with a mock peer name. Production callers\n should leave it on (caller's own ``node`` is the most common\n contagion target — see project_peer_name_canonical.md).\n \"\"\"\n # Self-name validation — best-effort. If the gateway is offline\n # we still want construction to succeed; the validation is\n # registry-dependent so use strict=False to skip the registry\n # check when offline.\n if validate_self_name:\n try:\n node = validate_node_name(node, strict=False)\n except Exception: # pragma: no cover — defensive\n pass\n\n self.node = node\n self._token = token if token is not None else os.environ.get(GATEWAY_TOKEN_ENV)\n self._gateway_url = (\n gateway_url\n or os.environ.get(\"MESH_GATEWAY_URL\")\n or DEFAULT_GATEWAY_URL\n )\n self._timeout = timeout_seconds\n self._client: Optional[httpx.AsyncClient] = None\n\n # ------------------------------------------------------------------\n # Lifecycle\n # ------------------------------------------------------------------\n\n def _ensure_client(self) -> httpx.AsyncClient:\n if self._client is None or self._client.is_closed:\n headers: dict[str, str] = {}\n if self._token:\n headers[\"Authorization\"] = f\"Bearer {self._token}\"\n self._client = httpx.AsyncClient(\n base_url=self._gateway_url,\n headers=headers,\n timeout=self._timeout,\n )\n return self._client\n\n async def aclose(self) -> None:\n if self._client is not None and not self._client.is_closed:\n await self._client.aclose()\n self._client = None\n\n async def __aenter__(self) -> \"MeshClient\":\n self._ensure_client()\n return self\n\n async def __aexit__(self, exc_type, exc, tb) -> None:\n await self.aclose()\n\n # ------------------------------------------------------------------\n # Internal HTTP wrapper\n # ------------------------------------------------------------------\n\n async def _request(\n self,\n method: str,\n path: str,\n *,\n params: Optional[dict[str, Any]] = None,\n json: Optional[dict[str, Any]] = None,\n ) -> Any:\n client = self._ensure_client()\n try:\n resp = await client.request(method, path, params=params, json=json)\n except httpx.RequestError as exc:\n raise MeshGatewayError(\n f\"mesh-gateway {method} {path} request failed: {exc}\"\n ) from exc\n\n if resp.status_code == 401 or resp.status_code == 403:\n raise MeshAuthError(\n f\"mesh-gateway {method} {path} returned {resp.status_code}: \"\n f\"check MESH_GATEWAY_TOKEN env\"\n )\n if resp.status_code >= 400:\n body = resp.text[:500]\n raise MeshGatewayError(\n f\"mesh-gateway {method} {path} returned {resp.status_code}: {body}\"\n )\n\n # Gateway returns JSON for everything except /health\n if not resp.content:\n return None\n try:\n return resp.json()\n except ValueError as exc:\n raise MeshGatewayError(\n f\"mesh-gateway {method} {path} returned non-JSON: {resp.text[:200]}\"\n ) from exc\n\n # ------------------------------------------------------------------\n # Peer endpoints\n # ------------------------------------------------------------------\n\n async def list_peers(self) -> list[MeshPeer]:\n \"\"\"``GET /peers`` — return all registered peers.\"\"\"\n payload = await self._request(\"GET\", \"/peers\")\n rows = payload.get(\"peers\", payload) if isinstance(payload, dict) else payload\n return [MeshPeer.model_validate(r) for r in rows]\n\n async def register(\n self,\n *,\n url: Optional[str] = None,\n capabilities: Optional[dict[str, Any]] = None,\n ) -> MeshPeer:\n \"\"\"``POST /peers/register`` — register this peer with the gateway.\"\"\"\n body: dict[str, Any] = {\"name\": self.node}\n if url is not None:\n body[\"url\"] = url\n if capabilities is not None:\n body[\"capabilities\"] = capabilities\n payload = await self._request(\"POST\", \"/peers/register\", json=body)\n return MeshPeer.model_validate(payload)\n\n # ------------------------------------------------------------------\n # Message endpoints\n # ------------------------------------------------------------------\n\n async def fetch(\n self,\n *,\n to_node: Optional[str] = None,\n unread_only: bool = False,\n limit: Optional[int] = None,\n ) -> list[MeshMessage]:\n \"\"\"``GET /messages`` — fetch own inbox.\n\n Defaults ``to_node`` to ``self.node``. Pass an explicit value\n only if you have a legitimate reason to peek at another peer's\n inbox (gateway access-controls this; most callers will get\n their own inbox only).\n\n v0.5.1 latent bug fix (discovered in Phase 5.6 daemon build):\n the mesh-gateway query parameter is ``?to=`` — NOT ``?to_node=``.\n The latter is silently ignored, returning ALL recent messages\n regardless of recipient. Pre-fix every ``MeshClient.fetch``\n caller saw outbound bleed-through unless they Python-side\n filtered. The kwarg keeps its descriptive name for the\n caller-facing API; only the wire-shape changes.\n \"\"\"\n # Defense-in-depth: filter from_node!=self_node client-side too,\n # so any future gateway quirk that re-introduces outbound\n # bleed-through doesn't silently break consumers (same shape\n # as the daemon's filter).\n target = to_node or self.node\n params: dict[str, Any] = {\"to\": target}\n if unread_only:\n params[\"unread_only\"] = \"true\"\n if limit is not None:\n params[\"limit\"] = limit\n payload = await self._request(\"GET\", \"/messages\", params=params)\n rows = payload.get(\"messages\", payload) if isinstance(payload, dict) else payload\n # Client-side defensive filter — only if fetching own inbox\n # (i.e., target == self.node). When peeking at another peer's\n # inbox via explicit to_node= override, return whatever gateway\n # returns without filtering (caller asked for raw view).\n if target == self.node:\n rows = [r for r in rows if r.get(\"from_node\") != self.node]\n return [MeshMessage.model_validate(r) for r in rows]\n\n async def send(\n self,\n *,\n to: str,\n kind: DM_KIND,\n content: str,\n related_task_id: Optional[str] = None,\n thread_id: Optional[str] = None,\n skip_secret_check: bool = False,\n ) -> MeshMessage:\n \"\"\"``POST /messages`` — send a DM.\n\n Validates ``to`` via ``swarph_shared.validate_node_name`` (closes\n Vector A + B contagion classes); raises ``ValueError`` /\n ``NotInRegistry`` on invalid recipient names BEFORE the POST.\n\n ``kind`` is a ``Literal`` enum (see ``DM_KIND`` above) — type\n checkers reject unknown values at lint time, and the runtime\n guard below catches anyone passing ``kind`` as ``str``-typed\n from a callsite that bypassed type-checking.\n\n Runs the best-effort credential leak detector on ``content``.\n Set ``skip_secret_check=True`` only when content has been\n inspected by the caller and the leak detector is producing a\n false positive (e.g. discussing a credential format in\n prose). False positives should be rare; treat them as a\n signal that the content is on a fence and rotate aggressively\n if you're not 100% sure.\n \"\"\"\n # Runtime kind enum check — defense-in-depth against str-typed\n # callsites that slipped past type-checking. Same shape as\n # validate_caller fail-fast discipline.\n if kind not in {\"fyi\", \"question\", \"answer\", \"status\", \"unblock\"}:\n raise ValueError(\n f\"MeshClient.send: kind={kind!r} is not a valid mesh-gateway \"\n f\"DM kind. Accepted: fyi / question / answer / status / unblock. \"\n f\"(Adding new kinds requires gateway-side support; check \"\n f\"server.py POST /messages handler.)\"\n )\n\n # Recipient validation — FAIL-CLOSED (strict=True). A send is about to\n # hit the same gateway via POST /messages, so the registry's /peers is\n # reachable in the normal case (and a warm TTL cache covers transient\n # blips). strict=False used to fail OPEN: a /peers hiccup skipped the\n # registry check and let a regex-valid-but-unregistered name through,\n # POSTing the DM into the void (adversarial-sweep auth-bypass HIGH).\n # Pass this client's own gateway_url + token so the registry check hits\n # the SAME gateway we POST to (not the MESH_GATEWAY_URL env default).\n canonical_to = validate_node_name(\n to, strict=True, gateway_url=self._gateway_url, token=self._token,\n )\n\n # Best-effort credential leak guard. Operator can disable for\n # legitimate prose mentioning credential shapes.\n if not skip_secret_check:\n _check_no_secret(content)\n\n body: dict[str, Any] = {\n \"from_node\": self.node,\n \"to_node\": canonical_to,\n \"kind\": kind,\n \"content\": content,\n }\n if related_task_id is not None:\n body[\"related_task_id\"] = related_task_id\n if thread_id is not None:\n body[\"thread_id\"] = thread_id\n\n payload = await self._request(\"POST\", \"/messages\", json=body)\n return MeshMessage.model_validate(payload)\n\n async def mark_read(self, msg_id: int) -> None:\n \"\"\"``POST /messages/{msg_id}/read`` — flip ``read_at`` on a DM.\n\n Per the mesh DM drain convention, the cron-side\n watcher does NOT mark read; only the commander or the\n peer-in-session does. Adapter callers consuming inboxes\n should mark-read explicitly when they've actually processed\n the DM, not on fetch.\n \"\"\"\n await self._request(\"POST\", f\"/messages/{msg_id}/read\")\n"}, "a57071bd891b71c47be414def9b46e164a74ecc9": {"t": 1785773544.5982952, "n": 19127, "h": "f770e7ce4a9de5dc", "c": "\"\"\"``MeshClient`` — async wrapper around the mesh-gateway HTTP API per\nPLAN.md §5.\n\nReplaces the hand-rolled curl-based code in:\n\n* ``lab-orchestrator/scripts/lab_loop_drain.py``\n* assorted ad-hoc curl invocations in CLAUDE.md / session ritual\n\nwith a single typed surface so cross-Claude DM coordination doesn't\nrequire re-discovering the gateway shape from scratch every time.\n\nPublic surface:\n\n client = MeshClient(node=\"my-cell\", token=os.environ[\"MESH_GATEWAY_TOKEN\"])\n peers = await client.list_peers()\n msgs = await client.fetch(unread_only=True)\n sent = await client.send(to=\"other-cell\", kind=\"fyi\", content=\"...\")\n await client.mark_read(msg_id=123)\n await client.register(url=\"http://localhost:8787\", capabilities={...})\n\nTwo structural invariants enforced:\n\n1. **Recipient name validation.** Every ``send()`` calls\n ``swarph_shared.validate_node_name`` on the recipient — closes the\n Vector A (peer-onboarding chatter) + Vector B (human-prompt\n shorthand) framing-contagion classes documented in\n ``project_peer_name_canonical.md``.\n\n2. **Mesh-secrets out-of-band guard.** Every ``send()`` runs a\n best-effort regex sniff on content for obvious credentials\n (``pypi-...``, ``sk-...``, ``gh[ops]_...``, ``eyJ...`` JWTs).\n Hits raise :class:`MeshSecretLeakError` BEFORE the POST. CLAUDE.md\n \"Mesh secrets out-of-band only — NEVER ride /messages content\n fields\" is the non-negotiable rule. Best-effort because regex\n misses are common — operators must still treat content fields as\n public. The guard catches the obvious cases.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport re\nfrom typing import Any, Literal, Optional\n\n\n# v0.5.1 (drop DM #722 + #728): client-side enum for mesh-gateway-accepted\n# DM kinds. Gateway validates server-side too; this is fail-fast at the\n# type-check + runtime layer so callers don't round-trip a known-bad value\n# (e.g., kind=\"review_request\" → 400). Same shape as the validate_caller\n# pattern from swarph_shared. Add new kinds here when the gateway ships\n# them (current accepted set per server.py mesh-gateway):\nDM_KIND = Literal[\"fyi\", \"question\", \"answer\", \"status\", \"unblock\"]\n\nimport httpx\nfrom swarph_shared import validate_node_name\n\nfrom swarph_mesh.exceptions import SwarphMeshError\nfrom swarph_mesh.mesh_types import MeshMessage, MeshPeer\n\n\nDEFAULT_GATEWAY_URL = \"http://localhost:8788\"\nGATEWAY_TOKEN_ENV = \"MESH_GATEWAY_TOKEN\"\nDEFAULT_TIMEOUT_SECONDS = 10.0\n\n\n# ---------------------------------------------------------------------------\n# Exceptions\n# ---------------------------------------------------------------------------\n\n\nclass MeshGatewayError(SwarphMeshError):\n \"\"\"Gateway returned non-2xx or otherwise non-conformant response.\"\"\"\n\n\nclass MeshAuthError(MeshGatewayError):\n \"\"\"Authentication failed — token missing or rejected (401/403).\"\"\"\n\n\nclass MeshSecretLeakError(SwarphMeshError):\n \"\"\"Best-effort guard caught an apparent credential in DM content.\n\n Refuses to send. Operators must rotate the credential and resend\n without it.\n \"\"\"\n\n\n# ---------------------------------------------------------------------------\n# Best-effort credential leak detector\n# ---------------------------------------------------------------------------\n\n# Patterns chosen to match observable shapes of credentials seen in\n# session JSONLs in the field. Updates here are cheap; misses are\n# expected (defense-in-depth, not strict validation).\n_CRED_PATTERNS: list[tuple[str, re.Pattern[str]]] = [\n (\"pypi token\", re.compile(r\"\\bpypi-[A-Za-z0-9_-]{20,}\")),\n (\"anthropic api key\", re.compile(r\"\\bsk-ant-[A-Za-z0-9_-]{20,}\")),\n # OpenAI ADMIN keys (highest privilege class — can create/delete keys,\n # manage org). Listed FIRST so it matches before the generic openai\n # pattern, giving the operator a properly scary error message.\n # Endpoint that mints these: POST /v1/organization/admin_api_keys.\n (\"openai ADMIN key (org-level privilege)\", re.compile(r\"\\bsk-admin-[A-Za-z0-9_-]{20,}\")),\n # OpenAI key shapes: legacy ``sk-<48hex>`` + project keys\n # ``sk-proj-<long>`` (newer, longer alphabet incl. ``_-``). Earlier\n # regex ``sk-[A-Za-z0-9]{20,}`` missed sk-proj-... because of the\n # underscore + hyphen in the body. Issue #4 tightening.\n # NOTE: ``sk-admin-...`` is matched by the named pattern above so\n # this catch-all doesn't downgrade an admin key's error severity.\n (\"openai api key\", re.compile(r\"\\bsk-(?!admin-)(?:proj-)?[A-Za-z0-9_-]{20,}\")),\n # xAI / Grok key — xai-<long>. Same shape as OpenAI's hyphen-prefix\n # pattern. Added in v0.5.1 alongside the Grok adapter shipped in\n # v0.5.0; would have matched the key the user pasted in chat earlier\n # this session if mesh send had been the destination.\n (\"xai api key\", re.compile(r\"\\bxai-[A-Za-z0-9_-]{40,}\")),\n # DeepSeek api key — sk-<32hex> hex-only post the sk- prefix.\n # Already covered loosely by the openai pattern but listing\n # explicitly so the error message names the right provider.\n (\"deepseek api key\", re.compile(r\"\\bsk-[a-f0-9]{32}\\b\")),\n # Google Gemini / Cloud API keys — ``AIza<35-alnum>`` format.\n # Common shape across Google's API tier; would catch GEMINI_API_KEY\n # leaks. Issue #4 tightening (was missing).\n (\"google api key\", re.compile(r\"\\bAIza[0-9A-Za-z_-]{35}\")),\n (\"github token\", re.compile(r\"\\bgh[ops]_[A-Za-z0-9]{30,}\")),\n (\"aws access key\", re.compile(r\"\\bAKIA[0-9A-Z]{16}\\b\")),\n (\"aws secret key\", re.compile(r\"\\b[A-Za-z0-9/+=]{40}\\b(?=.*aws)\")), # heuristic\n # JWT — three base64url segments separated by dots (stricter than the\n # generic shape so we don't false-fire on every blob with two dots).\n (\"jwt\", re.compile(r\"\\beyJ[A-Za-z0-9_-]{15,}\\.eyJ[A-Za-z0-9_-]{15,}\\.[A-Za-z0-9_-]{15,}\\b\")),\n # Mesh gateway tokens — long random base64url-shaped values stored in\n # MESH_GATEWAY_TOKEN env. Heuristic shape: 40+ chars of url-safe\n # base64 alphabet. False-positive rate higher than other patterns —\n # operator escape via skip_secret_check=True is the right hatch.\n (\"possible mesh-gateway token\", re.compile(r\"\\b[A-Za-z0-9_-]{48,}\\b(?=.*MESH_GATEWAY_TOKEN)\")),\n]\n\n\ndef _check_no_secret(content: str) -> None:\n \"\"\"Raise :class:`MeshSecretLeakError` if content looks like it\n carries a credential. Best-effort; expected to miss novel shapes.\n \"\"\"\n # Exact-match the LIVE gateway token value. The regex pattern below uses a\n # `(?=.*MESH_GATEWAY_TOKEN)` lookahead, so it only fires when the literal\n # string 'MESH_GATEWAY_TOKEN' co-occurs — a bare token PASTE (the common\n # leak) sailed through. Comparing against the actual env value is exact\n # (zero false positives) and catches that case. (adversarial-sweep MED.)\n env_tok = os.environ.get(GATEWAY_TOKEN_ENV, \"\").strip()\n if env_tok and len(env_tok) >= 16 and env_tok in content:\n raise MeshSecretLeakError(\n f\"refusing to send: content contains the live {GATEWAY_TOKEN_ENV} \"\n \"value. Mesh secrets out-of-band only — NEVER ride /messages content \"\n \"fields. (CLAUDE.md mesh-secrets rule.)\"\n )\n for label, rx in _CRED_PATTERNS:\n if rx.search(content):\n raise MeshSecretLeakError(\n f\"refusing to send: content matches {label} pattern. \"\n \"Mesh secrets out-of-band only — NEVER ride /messages \"\n \"content fields. Use ssh+tee, commander manual paste, \"\n \"or pre-redacted Hub records. (CLAUDE.md mesh-secrets rule.)\"\n )\n\n\n# ---------------------------------------------------------------------------\n# MeshClient\n# ---------------------------------------------------------------------------\n\n\nclass MeshClient:\n \"\"\"Async client for the mesh-gateway HTTP API.\n\n Construct once per peer (or per cycle), reuse for the lifetime of\n the work. Backed by ``httpx.AsyncClient`` so connection-pooling\n happens for free across many ``send()`` calls.\n\n Use as an async context manager when you want explicit close:\n\n async with MeshClient(node=\"my-cell\") as client:\n await client.send(to=\"other-cell\", kind=\"fyi\", content=\"...\")\n\n Or instantiate normally + call ``aclose()`` when done.\n \"\"\"\n\n def __init__(\n self,\n node: str,\n *,\n token: Optional[str] = None,\n gateway_url: Optional[str] = None,\n timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,\n validate_self_name: bool = True,\n ):\n \"\"\"``token`` falls back to ``MESH_GATEWAY_TOKEN`` env. ``gateway_url``\n falls back to ``MESH_GATEWAY_URL`` env, then ``http://localhost:8788``.\n\n Set ``validate_self_name=False`` for test fixtures that need to\n instantiate a client with a mock peer name. Production callers\n should leave it on (caller's own ``node`` is the most common\n contagion target — see project_peer_name_canonical.md).\n \"\"\"\n # Self-name validation — best-effort. If the gateway is offline\n # we still want construction to succeed; the validation is\n # registry-dependent so use strict=False to skip the registry\n # check when offline.\n if validate_self_name:\n try:\n node = validate_node_name(node, strict=False)\n except Exception: # pragma: no cover — defensive\n pass\n\n self.node = node\n self._token = token if token is not None else os.environ.get(GATEWAY_TOKEN_ENV)\n self._gateway_url = (\n gateway_url\n or os.environ.get(\"MESH_GATEWAY_URL\")\n or DEFAULT_GATEWAY_URL\n )\n self._timeout = timeout_seconds\n self._client: Optional[httpx.AsyncClient] = None\n\n # ------------------------------------------------------------------\n # Lifecycle\n # ------------------------------------------------------------------\n\n def _ensure_client(self) -> httpx.AsyncClient:\n if self._client is None or self._client.is_closed:\n headers: dict[str, str] = {}\n if self._token:\n headers[\"Authorization\"] = f\"Bearer {self._token}\"\n self._client = httpx.AsyncClient(\n base_url=self._gateway_url,\n headers=headers,\n timeout=self._timeout,\n )\n return self._client\n\n async def aclose(self) -> None:\n if self._client is not None and not self._client.is_closed:\n await self._client.aclose()\n self._client = None\n\n async def __aenter__(self) -> \"MeshClient\":\n self._ensure_client()\n return self\n\n async def __aexit__(self, exc_type, exc, tb) -> None:\n await self.aclose()\n\n # ------------------------------------------------------------------\n # Internal HTTP wrapper\n # ------------------------------------------------------------------\n\n async def _request(\n self,\n method: str,\n path: str,\n *,\n params: Optional[dict[str, Any]] = None,\n json: Optional[dict[str, Any]] = None,\n ) -> Any:\n client = self._ensure_client()\n try:\n resp = await client.request(method, path, params=params, json=json)\n except httpx.RequestError as exc:\n raise MeshGatewayError(\n f\"mesh-gateway {method} {path} request failed: {exc}\"\n ) from exc\n\n if resp.status_code == 401 or resp.status_code == 403:\n raise MeshAuthError(\n f\"mesh-gateway {method} {path} returned {resp.status_code}: \"\n f\"check MESH_GATEWAY_TOKEN env\"\n )\n if resp.status_code >= 400:\n body = resp.text[:500]\n raise MeshGatewayError(\n f\"mesh-gateway {method} {path} returned {resp.status_code}: {body}\"\n )\n\n # Gateway returns JSON for everything except /health\n if not resp.content:\n return None\n try:\n return resp.json()\n except ValueError as exc:\n raise MeshGatewayError(\n f\"mesh-gateway {method} {path} returned non-JSON: {resp.text[:200]}\"\n ) from exc\n\n # ------------------------------------------------------------------\n # Peer endpoints\n # ------------------------------------------------------------------\n\n async def list_peers(self) -> list[MeshPeer]:\n \"\"\"``GET /peers`` — return all registered peers.\"\"\"\n payload = await self._request(\"GET\", \"/peers\")\n rows = payload.get(\"peers\", payload) if isinstance(payload, dict) else payload\n return [MeshPeer.model_validate(r) for r in rows]\n\n async def register(\n self,\n *,\n url: Optional[str] = None,\n capabilities: Optional[dict[str, Any]] = None,\n ) -> MeshPeer:\n \"\"\"``POST /peers/register`` — register this peer with the gateway.\"\"\"\n body: dict[str, Any] = {\"name\": self.node}\n if url is not None:\n body[\"url\"] = url\n if capabilities is not None:\n body[\"capabilities\"] = capabilities\n payload = await self._request(\"POST\", \"/peers/register\", json=body)\n return MeshPeer.model_validate(payload)\n\n # ------------------------------------------------------------------\n # Message endpoints\n # ------------------------------------------------------------------\n\n async def fetch(\n self,\n *,\n to_node: Optional[str] = None,\n unread_only: bool = False,\n limit: Optional[int] = None,\n ) -> list[MeshMessage]:\n \"\"\"``GET /messages`` — fetch own inbox.\n\n Defaults ``to_node`` to ``self.node``. Pass an explicit value\n only if you have a legitimate reason to peek at another peer's\n inbox (gateway access-controls this; most callers will get\n their own inbox only).\n\n v0.5.1 latent bug fix (discovered in Phase 5.6 daemon build):\n the mesh-gateway query parameter is ``?to=`` — NOT ``?to_node=``.\n The latter is silently ignored, returning ALL recent messages\n regardless of recipient. Pre-fix every ``MeshClient.fetch``\n caller saw outbound bleed-through unless they Python-side\n filtered. The kwarg keeps its descriptive name for the\n caller-facing API; only the wire-shape changes.\n \"\"\"\n # Defense-in-depth: filter from_node!=self_node client-side too,\n # so any future gateway quirk that re-introduces outbound\n # bleed-through doesn't silently break consumers (same shape\n # as the daemon's filter).\n target = to_node or self.node\n params: dict[str, Any] = {\"to\": target}\n if unread_only:\n params[\"unread_only\"] = \"true\"\n if limit is not None:\n params[\"limit\"] = limit\n payload = await self._request(\"GET\", \"/messages\", params=params)\n rows = payload.get(\"messages\", payload) if isinstance(payload, dict) else payload\n # Client-side defensive filter — only if fetching own inbox\n # (i.e., target == self.node). When peeking at another peer's\n # inbox via explicit to_node= override, return whatever gateway\n # returns without filtering (caller asked for raw view).\n if target == self.node:\n rows = [r for r in rows if r.get(\"from_node\") != self.node]\n return [MeshMessage.model_validate(r) for r in rows]\n\n async def send(\n self,\n *,\n to: str,\n kind: DM_KIND,\n content: str,\n related_task_id: Optional[str] = None,\n thread_id: Optional[str] = None,\n skip_secret_check: bool = False,\n ) -> MeshMessage:\n \"\"\"``POST /messages`` — send a DM.\n\n Validates ``to`` via ``swarph_shared.validate_node_name`` (closes\n Vector A + B contagion classes); raises ``ValueError`` /\n ``NotInRegistry`` on invalid recipient names BEFORE the POST.\n\n ``kind`` is a ``Literal`` enum (see ``DM_KIND`` above) — type\n checkers reject unknown values at lint time, and the runtime\n guard below catches anyone passing ``kind`` as ``str``-typed\n from a callsite that bypassed type-checking.\n\n Runs the best-effort credential leak detector on ``content``.\n Set ``skip_secret_check=True`` only when content has been\n inspected by the caller and the leak detector is producing a\n false positive (e.g. discussing a credential format in\n prose). False positives should be rare; treat them as a\n signal that the content is on a fence and rotate aggressively\n if you're not 100% sure.\n \"\"\"\n # Runtime kind enum check — defense-in-depth against str-typed\n # callsites that slipped past type-checking. Same shape as\n # validate_caller fail-fast discipline.\n if kind not in {\"fyi\", \"question\", \"answer\", \"status\", \"unblock\"}:\n raise ValueError(\n f\"MeshClient.send: kind={kind!r} is not a valid mesh-gateway \"\n f\"DM kind. Accepted: fyi / question / answer / status / unblock. \"\n f\"(Adding new kinds requires gateway-side support; check \"\n f\"server.py POST /messages handler.)\"\n )\n\n # Recipient validation — FAIL-CLOSED (strict=True). A send is about to\n # hit the same gateway via POST /messages, so the registry's /peers is\n # reachable in the normal case (and a warm TTL cache covers transient\n # blips). strict=False used to fail OPEN: a /peers hiccup skipped the\n # registry check and let a regex-valid-but-unregistered name through,\n # POSTing the DM into the void (adversarial-sweep auth-bypass HIGH).\n # Pass this client's own gateway_url + token so the registry check hits\n # the SAME gateway we POST to (not the MESH_GATEWAY_URL env default).\n canonical_to = validate_node_name(\n to, strict=True, gateway_url=self._gateway_url, token=self._token,\n )\n\n # Best-effort credential leak guard. Operator can disable for\n # legitimate prose mentioning credential shapes.\n if not skip_secret_check:\n _check_no_secret(content)\n\n body: dict[str, Any] = {\n \"from_node\": self.node,\n \"to_node\": canonical_to,\n \"kind\": kind,\n \"content\": content,\n }\n if related_task_id is not None:\n body[\"related_task_id\"] = related_task_id\n if thread_id is not None:\n body[\"thread_id\"] = thread_id\n\n payload = await self._request(\"POST\", \"/messages\", json=body)\n return MeshMessage.model_validate(payload)\n\n async def mark_read(self, msg_id: int) -> None:\n \"\"\"``POST /messages/{msg_id}/read`` — flip ``read_at`` on a DM.\n\n Per the mesh DM drain convention, the cron-side\n watcher does NOT mark read; only the commander or the\n peer-in-session does. Adapter callers consuming inboxes\n should mark-read explicitly when they've actually processed\n the DM, not on fetch.\n \"\"\"\n await self._request(\"POST\", f\"/messages/{msg_id}/read\")\n"}, "6f7efbcdedd2b92ea1ff36cede2280a9352a503f": {"t": 1785773549.0391736, "n": 19253, "h": "f603e39cd112e3b5", "c": "\"\"\"``MeshClient`` — async wrapper around the mesh-gateway HTTP API per\nPLAN.md §5.\n\nReplaces the hand-rolled curl-based code in:\n\n* ``lab-orchestrator/scripts/lab_loop_drain.py``\n* ``hedge-fund-mcp/scripts/mesh_inbox_watcher.py`` (droplet-side)\n* ``hedge-fund-mcp/scripts/science_claude_inbox_drain.py``\n* assorted ad-hoc curl invocations in CLAUDE.md / session ritual\n\nwith a single typed surface so cross-Claude DM coordination doesn't\nrequire re-discovering the gateway shape from scratch every time.\n\nPublic surface:\n\n client = MeshClient(node=\"lab-ovh\", token=os.environ[\"MESH_GATEWAY_TOKEN\"])\n peers = await client.list_peers()\n msgs = await client.fetch(unread_only=True)\n sent = await client.send(to=\"droplet\", kind=\"fyi\", content=\"...\")\n await client.mark_read(msg_id=123)\n await client.register(url=\"http://lab-ovh:8787\", capabilities={...})\n\nTwo structural invariants enforced:\n\n1. **Recipient name validation.** Every ``send()`` calls\n ``swarph_shared.validate_node_name`` on the recipient — closes the\n Vector A (peer-onboarding chatter) + Vector B (human-prompt\n shorthand) framing-contagion classes documented in\n ``project_peer_name_canonical.md``.\n\n2. **Mesh-secrets out-of-band guard.** Every ``send()`` runs a\n best-effort regex sniff on content for obvious credentials\n (``pypi-...``, ``sk-...``, ``gh[ops]_...``, ``eyJ...`` JWTs).\n Hits raise :class:`MeshSecretLeakError` BEFORE the POST. CLAUDE.md\n \"Mesh secrets out-of-band only — NEVER ride /messages content\n fields\" is the non-negotiable rule. Best-effort because regex\n misses are common — operators must still treat content fields as\n public. The guard catches the obvious cases.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport re\nfrom typing import Any, Literal, Optional\n\n\n# v0.5.1 (drop DM #722 + #728): client-side enum for mesh-gateway-accepted\n# DM kinds. Gateway validates server-side too; this is fail-fast at the\n# type-check + runtime layer so callers don't round-trip a known-bad value\n# (e.g., kind=\"review_request\" → 400). Same shape as the validate_caller\n# pattern from swarph_shared. Add new kinds here when the gateway ships\n# them (current accepted set per server.py mesh-gateway):\nDM_KIND = Literal[\"fyi\", \"question\", \"answer\", \"status\", \"unblock\"]\n\nimport httpx\nfrom swarph_shared import validate_node_name\n\nfrom swarph_mesh.exceptions import SwarphMeshError\nfrom swarph_mesh.mesh_types import MeshMessage, MeshPeer\n\n\nDEFAULT_GATEWAY_URL = \"http://lab-ovh:8788\"\nGATEWAY_TOKEN_ENV = \"MESH_GATEWAY_TOKEN\"\nDEFAULT_TIMEOUT_SECONDS = 10.0\n\n\n# ---------------------------------------------------------------------------\n# Exceptions\n# ---------------------------------------------------------------------------\n\n\nclass MeshGatewayError(SwarphMeshError):\n \"\"\"Gateway returned non-2xx or otherwise non-conformant response.\"\"\"\n\n\nclass MeshAuthError(MeshGatewayError):\n \"\"\"Authentication failed — token missing or rejected (401/403).\"\"\"\n\n\nclass MeshSecretLeakError(SwarphMeshError):\n \"\"\"Best-effort guard caught an apparent credential in DM content.\n\n Refuses to send. Operators must rotate the credential and resend\n without it.\n \"\"\"\n\n\n# ---------------------------------------------------------------------------\n# Best-effort credential leak detector\n# ---------------------------------------------------------------------------\n\n# Patterns chosen to match observable shapes of credentials seen in\n# session JSONLs in the field. Updates here are cheap; misses are\n# expected (defense-in-depth, not strict validation).\n_CRED_PATTERNS: list[tuple[str, re.Pattern[str]]] = [\n (\"pypi token\", re.compile(r\"\\bpypi-[A-Za-z0-9_-]{20,}\")),\n (\"anthropic api key\", re.compile(r\"\\bsk-ant-[A-Za-z0-9_-]{20,}\")),\n # OpenAI ADMIN keys (highest privilege class — can create/delete keys,\n # manage org). Listed FIRST so it matches before the generic openai\n # pattern, giving the operator a properly scary error message.\n # Endpoint that mints these: POST /v1/organization/admin_api_keys.\n (\"openai ADMIN key (org-level privilege)\", re.compile(r\"\\bsk-admin-[A-Za-z0-9_-]{20,}\")),\n # OpenAI key shapes: legacy ``sk-<48hex>`` + project keys\n # ``sk-proj-<long>`` (newer, longer alphabet incl. ``_-``). Earlier\n # regex ``sk-[A-Za-z0-9]{20,}`` missed sk-proj-... because of the\n # underscore + hyphen in the body. Issue #4 tightening.\n # NOTE: ``sk-admin-...`` is matched by the named pattern above so\n # this catch-all doesn't downgrade an admin key's error severity.\n (\"openai api key\", re.compile(r\"\\bsk-(?!admin-)(?:proj-)?[A-Za-z0-9_-]{20,}\")),\n # xAI / Grok key — xai-<long>. Same shape as OpenAI's hyphen-prefix\n # pattern. Added in v0.5.1 alongside the Grok adapter shipped in\n # v0.5.0; would have matched the key the user pasted in chat earlier\n # this session if mesh send had been the destination.\n (\"xai api key\", re.compile(r\"\\bxai-[A-Za-z0-9_-]{40,}\")),\n # DeepSeek api key — sk-<32hex> hex-only post the sk- prefix.\n # Already covered loosely by the openai pattern but listing\n # explicitly so the error message names the right provider.\n (\"deepseek api key\", re.compile(r\"\\bsk-[a-f0-9]{32}\\b\")),\n # Google Gemini / Cloud API keys — ``AIza<35-alnum>`` format.\n # Common shape across Google's API tier; would catch GEMINI_API_KEY\n # leaks. Issue #4 tightening (was missing).\n (\"google api key\", re.compile(r\"\\bAIza[0-9A-Za-z_-]{35}\")),\n (\"github token\", re.compile(r\"\\bgh[ops]_[A-Za-z0-9]{30,}\")),\n (\"aws access key\", re.compile(r\"\\bAKIA[0-9A-Z]{16}\\b\")),\n (\"aws secret key\", re.compile(r\"\\b[A-Za-z0-9/+=]{40}\\b(?=.*aws)\")), # heuristic\n # JWT — three base64url segments separated by dots (stricter than the\n # generic shape so we don't false-fire on every blob with two dots).\n (\"jwt\", re.compile(r\"\\beyJ[A-Za-z0-9_-]{15,}\\.eyJ[A-Za-z0-9_-]{15,}\\.[A-Za-z0-9_-]{15,}\\b\")),\n # Mesh gateway tokens — long random base64url-shaped values stored in\n # MESH_GATEWAY_TOKEN env. Heuristic shape: 40+ chars of url-safe\n # base64 alphabet. False-positive rate higher than other patterns —\n # operator escape via skip_secret_check=True is the right hatch.\n (\"possible mesh-gateway token\", re.compile(r\"\\b[A-Za-z0-9_-]{48,}\\b(?=.*MESH_GATEWAY_TOKEN)\")),\n]\n\n\ndef _check_no_secret(content: str) -> None:\n \"\"\"Raise :class:`MeshSecretLeakError` if content looks like it\n carries a credential. Best-effort; expected to miss novel shapes.\n \"\"\"\n # Exact-match the LIVE gateway token value. The regex pattern below uses a\n # `(?=.*MESH_GATEWAY_TOKEN)` lookahead, so it only fires when the literal\n # string 'MESH_GATEWAY_TOKEN' co-occurs — a bare token PASTE (the common\n # leak) sailed through. Comparing against the actual env value is exact\n # (zero false positives) and catches that case. (adversarial-sweep MED.)\n env_tok = os.environ.get(GATEWAY_TOKEN_ENV, \"\").strip()\n if env_tok and len(env_tok) >= 16 and env_tok in content:\n raise MeshSecretLeakError(\n f\"refusing to send: content contains the live {GATEWAY_TOKEN_ENV} \"\n \"value. Mesh secrets out-of-band only — NEVER ride /messages content \"\n \"fields. (CLAUDE.md mesh-secrets rule.)\"\n )\n for label, rx in _CRED_PATTERNS:\n if rx.search(content):\n raise MeshSecretLeakError(\n f\"refusing to send: content matches {label} pattern. \"\n \"Mesh secrets out-of-band only — NEVER ride /messages \"\n \"content fields. Use ssh+tee, commander manual paste, \"\n \"or pre-redacted Hub records. (CLAUDE.md mesh-secrets rule.)\"\n )\n\n\n# ---------------------------------------------------------------------------\n# MeshClient\n# ---------------------------------------------------------------------------\n\n\nclass MeshClient:\n \"\"\"Async client for the mesh-gateway HTTP API.\n\n Construct once per peer (or per cycle), reuse for the lifetime of\n the work. Backed by ``httpx.AsyncClient`` so connection-pooling\n happens for free across many ``send()`` calls.\n\n Use as an async context manager when you want explicit close:\n\n async with MeshClient(node=\"lab-ovh\") as client:\n await client.send(to=\"droplet\", kind=\"fyi\", content=\"...\")\n\n Or instantiate normally + call ``aclose()`` when done.\n \"\"\"\n\n def __init__(\n self,\n node: str,\n *,\n token: Optional[str] = None,\n gateway_url: Optional[str] = None,\n timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,\n validate_self_name: bool = True,\n ):\n \"\"\"``token`` falls back to ``MESH_GATEWAY_TOKEN`` env. ``gateway_url``\n falls back to ``MESH_GATEWAY_URL`` env, then ``http://lab-ovh:8788``.\n\n Set ``validate_self_name=False`` for test fixtures that need to\n instantiate a client with a mock peer name. Production callers\n should leave it on (caller's own ``node`` is the most common\n contagion target — see project_peer_name_canonical.md).\n \"\"\"\n # Self-name validation — best-effort. If the gateway is offline\n # we still want construction to succeed; the validation is\n # registry-dependent so use strict=False to skip the registry\n # check when offline.\n if validate_self_name:\n try:\n node = validate_node_name(node, strict=False)\n except Exception: # pragma: no cover — defensive\n pass\n\n self.node = node\n self._token = token if token is not None else os.environ.get(GATEWAY_TOKEN_ENV)\n self._gateway_url = (\n gateway_url\n or os.environ.get(\"MESH_GATEWAY_URL\")\n or DEFAULT_GATEWAY_URL\n )\n self._timeout = timeout_seconds\n self._client: Optional[httpx.AsyncClient] = None\n\n # ------------------------------------------------------------------\n # Lifecycle\n # ------------------------------------------------------------------\n\n def _ensure_client(self) -> httpx.AsyncClient:\n if self._client is None or self._client.is_closed:\n headers: dict[str, str] = {}\n if self._token:\n headers[\"Authorization\"] = f\"Bearer {self._token}\"\n self._client = httpx.AsyncClient(\n base_url=self._gateway_url,\n headers=headers,\n timeout=self._timeout,\n )\n return self._client\n\n async def aclose(self) -> None:\n if self._client is not None and not self._client.is_closed:\n await self._client.aclose()\n self._client = None\n\n async def __aenter__(self) -> \"MeshClient\":\n self._ensure_client()\n return self\n\n async def __aexit__(self, exc_type, exc, tb) -> None:\n await self.aclose()\n\n # ------------------------------------------------------------------\n # Internal HTTP wrapper\n # ------------------------------------------------------------------\n\n async def _request(\n self,\n method: str,\n path: str,\n *,\n params: Optional[dict[str, Any]] = None,\n json: Optional[dict[str, Any]] = None,\n ) -> Any:\n client = self._ensure_client()\n try:\n resp = await client.request(method, path, params=params, json=json)\n except httpx.RequestError as exc:\n raise MeshGatewayError(\n f\"mesh-gateway {method} {path} request failed: {exc}\"\n ) from exc\n\n if resp.status_code == 401 or resp.status_code == 403:\n raise MeshAuthError(\n f\"mesh-gateway {method} {path} returned {resp.status_code}: \"\n f\"check MESH_GATEWAY_TOKEN env\"\n )\n if resp.status_code >= 400:\n body = resp.text[:500]\n raise MeshGatewayError(\n f\"mesh-gateway {method} {path} returned {resp.status_code}: {body}\"\n )\n\n # Gateway returns JSON for everything except /health\n if not resp.content:\n return None\n try:\n return resp.json()\n except ValueError as exc:\n raise MeshGatewayError(\n f\"mesh-gateway {method} {path} returned non-JSON: {resp.text[:200]}\"\n ) from exc\n\n # ------------------------------------------------------------------\n # Peer endpoints\n # ------------------------------------------------------------------\n\n async def list_peers(self) -> list[MeshPeer]:\n \"\"\"``GET /peers`` — return all registered peers.\"\"\"\n payload = await self._request(\"GET\", \"/peers\")\n rows = payload.get(\"peers\", payload) if isinstance(payload, dict) else payload\n return [MeshPeer.model_validate(r) for r in rows]\n\n async def register(\n self,\n *,\n url: Optional[str] = None,\n capabilities: Optional[dict[str, Any]] = None,\n ) -> MeshPeer:\n \"\"\"``POST /peers/register`` — register this peer with the gateway.\"\"\"\n body: dict[str, Any] = {\"name\": self.node}\n if url is not None:\n body[\"url\"] = url\n if capabilities is not None:\n body[\"capabilities\"] = capabilities\n payload = await self._request(\"POST\", \"/peers/register\", json=body)\n return MeshPeer.model_validate(payload)\n\n # ------------------------------------------------------------------\n # Message endpoints\n # ------------------------------------------------------------------\n\n async def fetch(\n self,\n *,\n to_node: Optional[str] = None,\n unread_only: bool = False,\n limit: Optional[int] = None,\n ) -> list[MeshMessage]:\n \"\"\"``GET /messages`` — fetch own inbox.\n\n Defaults ``to_node`` to ``self.node``. Pass an explicit value\n only if you have a legitimate reason to peek at another peer's\n inbox (gateway access-controls this; most callers will get\n their own inbox only).\n\n v0.5.1 latent bug fix (discovered in Phase 5.6 daemon build):\n the mesh-gateway query parameter is ``?to=`` — NOT ``?to_node=``.\n The latter is silently ignored, returning ALL recent messages\n regardless of recipient. Pre-fix every ``MeshClient.fetch``\n caller saw outbound bleed-through unless they Python-side\n filtered. The kwarg keeps its descriptive name for the\n caller-facing API; only the wire-shape changes.\n \"\"\"\n # Defense-in-depth: filter from_node!=self_node client-side too,\n # so any future gateway quirk that re-introduces outbound\n # bleed-through doesn't silently break consumers (same shape\n # as the daemon's filter).\n target = to_node or self.node\n params: dict[str, Any] = {\"to\": target}\n if unread_only:\n params[\"unread_only\"] = \"true\"\n if limit is not None:\n params[\"limit\"] = limit\n payload = await self._request(\"GET\", \"/messages\", params=params)\n rows = payload.get(\"messages\", payload) if isinstance(payload, dict) else payload\n # Client-side defensive filter — only if fetching own inbox\n # (i.e., target == self.node). When peeking at another peer's\n # inbox via explicit to_node= override, return whatever gateway\n # returns without filtering (caller asked for raw view).\n if target == self.node:\n rows = [r for r in rows if r.get(\"from_node\") != self.node]\n return [MeshMessage.model_validate(r) for r in rows]\n\n async def send(\n self,\n *,\n to: str,\n kind: DM_KIND,\n content: str,\n related_task_id: Optional[str] = None,\n thread_id: Optional[str] = None,\n skip_secret_check: bool = False,\n ) -> MeshMessage:\n \"\"\"``POST /messages`` — send a DM.\n\n Validates ``to`` via ``swarph_shared.validate_node_name`` (closes\n Vector A + B contagion classes); raises ``ValueError`` /\n ``NotInRegistry`` on invalid recipient names BEFORE the POST.\n\n ``kind`` is a ``Literal`` enum (see ``DM_KIND`` above) — type\n checkers reject unknown values at lint time, and the runtime\n guard below catches anyone passing ``kind`` as ``str``-typed\n from a callsite that bypassed type-checking.\n\n Runs the best-effort credential leak detector on ``content``.\n Set ``skip_secret_check=True`` only when content has been\n inspected by the caller and the leak detector is producing a\n false positive (e.g. discussing a credential format in\n prose). False positives should be rare; treat them as a\n signal that the content is on a fence and rotate aggressively\n if you're not 100% sure.\n \"\"\"\n # Runtime kind enum check — defense-in-depth against str-typed\n # callsites that slipped past type-checking. Same shape as\n # validate_caller fail-fast discipline.\n if kind not in {\"fyi\", \"question\", \"answer\", \"status\", \"unblock\"}:\n raise ValueError(\n f\"MeshClient.send: kind={kind!r} is not a valid mesh-gateway \"\n f\"DM kind. Accepted: fyi / question / answer / status / unblock. \"\n f\"(Adding new kinds requires gateway-side support; check \"\n f\"server.py POST /messages handler.)\"\n )\n\n # Recipient validation — FAIL-CLOSED (strict=True). A send is about to\n # hit the same gateway via POST /messages, so the registry's /peers is\n # reachable in the normal case (and a warm TTL cache covers transient\n # blips). strict=False used to fail OPEN: a /peers hiccup skipped the\n # registry check and let a regex-valid-but-unregistered name through,\n # POSTing the DM into the void (adversarial-sweep auth-bypass HIGH).\n # Pass this client's own gateway_url + token so the registry check hits\n # the SAME gateway we POST to (not the MESH_GATEWAY_URL env default).\n canonical_to = validate_node_name(\n to, strict=True, gateway_url=self._gateway_url, token=self._token,\n )\n\n # Best-effort credential leak guard. Operator can disable for\n # legitimate prose mentioning credential shapes.\n if not skip_secret_check:\n _check_no_secret(content)\n\n body: dict[str, Any] = {\n \"from_node\": self.node,\n \"to_node\": canonical_to,\n \"kind\": kind,\n \"content\": content,\n }\n if related_task_id is not None:\n body[\"related_task_id\"] = related_task_id\n if thread_id is not None:\n body[\"thread_id\"] = thread_id\n\n payload = await self._request(\"POST\", \"/messages\", json=body)\n return MeshMessage.model_validate(payload)\n\n async def mark_read(self, msg_id: int) -> None:\n \"\"\"``POST /messages/{msg_id}/read`` — flip ``read_at`` on a DM.\n\n Per CLAUDE.md droplet-side mesh DM drain rule, the cron-side\n watcher does NOT mark read; only the commander or the\n peer-in-session does. Adapter callers consuming inboxes\n should mark-read explicitly when they've actually processed\n the DM, not on fetch.\n \"\"\"\n await self._request(\"POST\", f\"/messages/{msg_id}/read\")\n"}, "49890f7342dc41eff133ecb80ccee34a322f0925": {"t": 1785773552.4684608, "n": 18483, "h": "6db45c5227f06ce5", "c": "\"\"\"``MeshClient`` — async wrapper around the mesh-gateway HTTP API per\nPLAN.md §5.\n\nReplaces the hand-rolled curl-based code in:\n\n* ``lab-orchestrator/scripts/lab_loop_drain.py``\n* ``hedge-fund-mcp/scripts/mesh_inbox_watcher.py`` (droplet-side)\n* ``hedge-fund-mcp/scripts/science_claude_inbox_drain.py``\n* assorted ad-hoc curl invocations in CLAUDE.md / session ritual\n\nwith a single typed surface so cross-Claude DM coordination doesn't\nrequire re-discovering the gateway shape from scratch every time.\n\nPublic surface:\n\n client = MeshClient(node=\"lab-ovh\", token=os.environ[\"MESH_GATEWAY_TOKEN\"])\n peers = await client.list_peers()\n msgs = await client.fetch(unread_only=True)\n sent = await client.send(to=\"droplet\", kind=\"fyi\", content=\"...\")\n await client.mark_read(msg_id=123)\n await client.register(url=\"http://lab-ovh:8787\", capabilities={...})\n\nTwo structural invariants enforced:\n\n1. **Recipient name validation.** Every ``send()`` calls\n ``swarph_shared.validate_node_name`` on the recipient — closes the\n Vector A (peer-onboarding chatter) + Vector B (human-prompt\n shorthand) framing-contagion classes documented in\n ``project_peer_name_canonical.md``.\n\n2. **Mesh-secrets out-of-band guard.** Every ``send()`` runs a\n best-effort regex sniff on content for obvious credentials\n (``pypi-...``, ``sk-...``, ``gh[ops]_...``, ``eyJ...`` JWTs).\n Hits raise :class:`MeshSecretLeakError` BEFORE the POST. CLAUDE.md\n \"Mesh secrets out-of-band only — NEVER ride /messages content\n fields\" is the non-negotiable rule. Best-effort because regex\n misses are common — operators must still treat content fields as\n public. The guard catches the obvious cases.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport re\nfrom typing import Any, Literal, Optional\n\n\n# v0.5.1 (drop DM #722 + #728): client-side enum for mesh-gateway-accepted\n# DM kinds. Gateway validates server-side too; this is fail-fast at the\n# type-check + runtime layer so callers don't round-trip a known-bad value\n# (e.g., kind=\"review_request\" → 400). Same shape as the validate_caller\n# pattern from swarph_shared. Add new kinds here when the gateway ships\n# them (current accepted set per server.py mesh-gateway):\nDM_KIND = Literal[\"fyi\", \"question\", \"answer\", \"status\", \"unblock\"]\n\nimport httpx\nfrom swarph_shared import validate_node_name\n\nfrom swarph_mesh.exceptions import SwarphMeshError\nfrom swarph_mesh.mesh_types import MeshMessage, MeshPeer\n\n\nDEFAULT_GATEWAY_URL = \"http://lab-ovh:8788\"\nGATEWAY_TOKEN_ENV = \"MESH_GATEWAY_TOKEN\"\nDEFAULT_TIMEOUT_SECONDS = 10.0\n\n\n# ---------------------------------------------------------------------------\n# Exceptions\n# ---------------------------------------------------------------------------\n\n\nclass MeshGatewayError(SwarphMeshError):\n \"\"\"Gateway returned non-2xx or otherwise non-conformant response.\"\"\"\n\n\nclass MeshAuthError(MeshGatewayError):\n \"\"\"Authentication failed — token missing or rejected (401/403).\"\"\"\n\n\nclass MeshSecretLeakError(SwarphMeshError):\n \"\"\"Best-effort guard caught an apparent credential in DM content.\n\n Refuses to send. Operators must rotate the credential and resend\n without it.\n \"\"\"\n\n\n# ---------------------------------------------------------------------------\n# Best-effort credential leak detector\n# ---------------------------------------------------------------------------\n\n# Patterns chosen to match observable shapes of credentials seen in\n# session JSONLs in the field. Updates here are cheap; misses are\n# expected (defense-in-depth, not strict validation).\n_CRED_PATTERNS: list[tuple[str, re.Pattern[str]]] = [\n (\"pypi token\", re.compile(r\"\\bpypi-[A-Za-z0-9_-]{20,}\")),\n (\"anthropic api key\", re.compile(r\"\\bsk-ant-[A-Za-z0-9_-]{20,}\")),\n # OpenAI ADMIN keys (highest privilege class — can create/delete keys,\n # manage org). Listed FIRST so it matches before the generic openai\n # pattern, giving the operator a properly scary error message.\n # Endpoint that mints these: POST /v1/organization/admin_api_keys.\n (\"openai ADMIN key (org-level privilege)\", re.compile(r\"\\bsk-admin-[A-Za-z0-9_-]{20,}\")),\n # OpenAI key shapes: legacy ``sk-<48hex>`` + project keys\n # ``sk-proj-<long>`` (newer, longer alphabet incl. ``_-``). Earlier\n # regex ``sk-[A-Za-z0-9]{20,}`` missed sk-proj-... because of the\n # underscore + hyphen in the body. Issue #4 tightening.\n # NOTE: ``sk-admin-...`` is matched by the named pattern above so\n # this catch-all doesn't downgrade an admin key's error severity.\n (\"openai api key\", re.compile(r\"\\bsk-(?!admin-)(?:proj-)?[A-Za-z0-9_-]{20,}\")),\n # xAI / Grok key — xai-<long>. Same shape as OpenAI's hyphen-prefix\n # pattern. Added in v0.5.1 alongside the Grok adapter shipped in\n # v0.5.0; would have matched the key the user pasted in chat earlier\n # this session if mesh send had been the destination.\n (\"xai api key\", re.compile(r\"\\bxai-[A-Za-z0-9_-]{40,}\")),\n # DeepSeek api key — sk-<32hex> hex-only post the sk- prefix.\n # Already covered loosely by the openai pattern but listing\n # explicitly so the error message names the right provider.\n (\"deepseek api key\", re.compile(r\"\\bsk-[a-f0-9]{32}\\b\")),\n # Google Gemini / Cloud API keys — ``AIza<35-alnum>`` format.\n # Common shape across Google's API tier; would catch GEMINI_API_KEY\n # leaks. Issue #4 tightening (was missing).\n (\"google api key\", re.compile(r\"\\bAIza[0-9A-Za-z_-]{35}\")),\n (\"github token\", re.compile(r\"\\bgh[ops]_[A-Za-z0-9]{30,}\")),\n (\"aws access key\", re.compile(r\"\\bAKIA[0-9A-Z]{16}\\b\")),\n (\"aws secret key\", re.compile(r\"\\b[A-Za-z0-9/+=]{40}\\b(?=.*aws)\")), # heuristic\n # JWT — three base64url segments separated by dots (stricter than the\n # generic shape so we don't false-fire on every blob with two dots).\n (\"jwt\", re.compile(r\"\\beyJ[A-Za-z0-9_-]{15,}\\.eyJ[A-Za-z0-9_-]{15,}\\.[A-Za-z0-9_-]{15,}\\b\")),\n # Mesh gateway tokens — long random base64url-shaped values stored in\n # MESH_GATEWAY_TOKEN env. Heuristic shape: 40+ chars of url-safe\n # base64 alphabet. False-positive rate higher than other patterns —\n # operator escape via skip_secret_check=True is the right hatch.\n (\"possible mesh-gateway token\", re.compile(r\"\\b[A-Za-z0-9_-]{48,}\\b(?=.*MESH_GATEWAY_TOKEN)\")),\n]\n\n\ndef _check_no_secret(content: str) -> None:\n \"\"\"Raise :class:`MeshSecretLeakError` if content looks like it\n carries a credential. Best-effort; expected to miss novel shapes.\n \"\"\"\n for label, rx in _CRED_PATTERNS:\n if rx.search(content):\n raise MeshSecretLeakError(\n f\"refusing to send: content matches {label} pattern. \"\n \"Mesh secrets out-of-band only — NEVER ride /messages \"\n \"content fields. Use ssh+tee, commander manual paste, \"\n \"or pre-redacted Hub records. (CLAUDE.md mesh-secrets rule.)\"\n )\n\n\n# ---------------------------------------------------------------------------\n# MeshClient\n# ---------------------------------------------------------------------------\n\n\nclass MeshClient:\n \"\"\"Async client for the mesh-gateway HTTP API.\n\n Construct once per peer (or per cycle), reuse for the lifetime of\n the work. Backed by ``httpx.AsyncClient`` so connection-pooling\n happens for free across many ``send()`` calls.\n\n Use as an async context manager when you want explicit close:\n\n async with MeshClient(node=\"lab-ovh\") as client:\n await client.send(to=\"droplet\", kind=\"fyi\", content=\"...\")\n\n Or instantiate normally + call ``aclose()`` when done.\n \"\"\"\n\n def __init__(\n self,\n node: str,\n *,\n token: Optional[str] = None,\n gateway_url: Optional[str] = None,\n timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,\n validate_self_name: bool = True,\n ):\n \"\"\"``token`` falls back to ``MESH_GATEWAY_TOKEN`` env. ``gateway_url``\n falls back to ``MESH_GATEWAY_URL`` env, then ``http://lab-ovh:8788``.\n\n Set ``validate_self_name=False`` for test fixtures that need to\n instantiate a client with a mock peer name. Production callers\n should leave it on (caller's own ``node`` is the most common\n contagion target — see project_peer_name_canonical.md).\n \"\"\"\n # Self-name validation — best-effort. If the gateway is offline\n # we still want construction to succeed; the validation is\n # registry-dependent so use strict=False to skip the registry\n # check when offline.\n if validate_self_name:\n try:\n node = validate_node_name(node, strict=False)\n except Exception: # pragma: no cover — defensive\n pass\n\n self.node = node\n self._token = token if token is not None else os.environ.get(GATEWAY_TOKEN_ENV)\n self._gateway_url = (\n gateway_url\n or os.environ.get(\"MESH_GATEWAY_URL\")\n or DEFAULT_GATEWAY_URL\n )\n self._timeout = timeout_seconds\n self._client: Optional[httpx.AsyncClient] = None\n\n # ------------------------------------------------------------------\n # Lifecycle\n # ------------------------------------------------------------------\n\n def _ensure_client(self) -> httpx.AsyncClient:\n if self._client is None or self._client.is_closed:\n headers: dict[str, str] = {}\n if self._token:\n headers[\"Authorization\"] = f\"Bearer {self._token}\"\n self._client = httpx.AsyncClient(\n base_url=self._gateway_url,\n headers=headers,\n timeout=self._timeout,\n )\n return self._client\n\n async def aclose(self) -> None:\n if self._client is not None and not self._client.is_closed:\n await self._client.aclose()\n self._client = None\n\n async def __aenter__(self) -> \"MeshClient\":\n self._ensure_client()\n return self\n\n async def __aexit__(self, exc_type, exc, tb) -> None:\n await self.aclose()\n\n # ------------------------------------------------------------------\n # Internal HTTP wrapper\n # ------------------------------------------------------------------\n\n async def _request(\n self,\n method: str,\n path: str,\n *,\n params: Optional[dict[str, Any]] = None,\n json: Optional[dict[str, Any]] = None,\n ) -> Any:\n client = self._ensure_client()\n try:\n resp = await client.request(method, path, params=params, json=json)\n except httpx.RequestError as exc:\n raise MeshGatewayError(\n f\"mesh-gateway {method} {path} request failed: {exc}\"\n ) from exc\n\n if resp.status_code == 401 or resp.status_code == 403:\n raise MeshAuthError(\n f\"mesh-gateway {method} {path} returned {resp.status_code}: \"\n f\"check MESH_GATEWAY_TOKEN env\"\n )\n if resp.status_code >= 400:\n body = resp.text[:500]\n raise MeshGatewayError(\n f\"mesh-gateway {method} {path} returned {resp.status_code}: {body}\"\n )\n\n # Gateway returns JSON for everything except /health\n if not resp.content:\n return None\n try:\n return resp.json()\n except ValueError as exc:\n raise MeshGatewayError(\n f\"mesh-gateway {method} {path} returned non-JSON: {resp.text[:200]}\"\n ) from exc\n\n # ------------------------------------------------------------------\n # Peer endpoints\n # ------------------------------------------------------------------\n\n async def list_peers(self) -> list[MeshPeer]:\n \"\"\"``GET /peers`` — return all registered peers.\"\"\"\n payload = await self._request(\"GET\", \"/peers\")\n rows = payload.get(\"peers\", payload) if isinstance(payload, dict) else payload\n return [MeshPeer.model_validate(r) for r in rows]\n\n async def register(\n self,\n *,\n url: Optional[str] = None,\n capabilities: Optional[dict[str, Any]] = None,\n ) -> MeshPeer:\n \"\"\"``POST /peers/register`` — register this peer with the gateway.\"\"\"\n body: dict[str, Any] = {\"name\": self.node}\n if url is not None:\n body[\"url\"] = url\n if capabilities is not None:\n body[\"capabilities\"] = capabilities\n payload = await self._request(\"POST\", \"/peers/register\", json=body)\n return MeshPeer.model_validate(payload)\n\n # ------------------------------------------------------------------\n # Message endpoints\n # ------------------------------------------------------------------\n\n async def fetch(\n self,\n *,\n to_node: Optional[str] = None,\n unread_only: bool = False,\n limit: Optional[int] = None,\n ) -> list[MeshMessage]:\n \"\"\"``GET /messages`` — fetch own inbox.\n\n Defaults ``to_node`` to ``self.node``. Pass an explicit value\n only if you have a legitimate reason to peek at another peer's\n inbox (gateway access-controls this; most callers will get\n their own inbox only).\n\n v0.5.1 latent bug fix (discovered in Phase 5.6 daemon build):\n the mesh-gateway query parameter is ``?to=`` — NOT ``?to_node=``.\n The latter is silently ignored, returning ALL recent messages\n regardless of recipient. Pre-fix every ``MeshClient.fetch``\n caller saw outbound bleed-through unless they Python-side\n filtered. The kwarg keeps its descriptive name for the\n caller-facing API; only the wire-shape changes.\n \"\"\"\n # Defense-in-depth: filter from_node!=self_node client-side too,\n # so any future gateway quirk that re-introduces outbound\n # bleed-through doesn't silently break consumers (same shape\n # as the daemon's filter).\n target = to_node or self.node\n params: dict[str, Any] = {\"to\": target}\n if unread_only:\n params[\"unread_only\"] = \"true\"\n if limit is not None:\n params[\"limit\"] = limit\n payload = await self._request(\"GET\", \"/messages\", params=params)\n rows = payload.get(\"messages\", payload) if isinstance(payload, dict) else payload\n # Client-side defensive filter — only if fetching own inbox\n # (i.e., target == self.node). When peeking at another peer's\n # inbox via explicit to_node= override, return whatever gateway\n # returns without filtering (caller asked for raw view).\n if target == self.node:\n rows = [r for r in rows if r.get(\"from_node\") != self.node]\n return [MeshMessage.model_validate(r) for r in rows]\n\n async def send(\n self,\n *,\n to: str,\n kind: DM_KIND,\n content: str,\n related_task_id: Optional[str] = None,\n thread_id: Optional[str] = None,\n skip_secret_check: bool = False,\n ) -> MeshMessage:\n \"\"\"``POST /messages`` — send a DM.\n\n Validates ``to`` via ``swarph_shared.validate_node_name`` (closes\n Vector A + B contagion classes); raises ``ValueError`` /\n ``NotInRegistry`` on invalid recipient names BEFORE the POST.\n\n ``kind`` is a ``Literal`` enum (see ``DM_KIND`` above) — type\n checkers reject unknown values at lint time, and the runtime\n guard below catches anyone passing ``kind`` as ``str``-typed\n from a callsite that bypassed type-checking.\n\n Runs the best-effort credential leak detector on ``content``.\n Set ``skip_secret_check=True`` only when content has been\n inspected by the caller and the leak detector is producing a\n false positive (e.g. discussing a credential format in\n prose). False positives should be rare; treat them as a\n signal that the content is on a fence and rotate aggressively\n if you're not 100% sure.\n \"\"\"\n # Runtime kind enum check — defense-in-depth against str-typed\n # callsites that slipped past type-checking. Same shape as\n # validate_caller fail-fast discipline.\n if kind not in {\"fyi\", \"question\", \"answer\", \"status\", \"unblock\"}:\n raise ValueError(\n f\"MeshClient.send: kind={kind!r} is not a valid mesh-gateway \"\n f\"DM kind. Accepted: fyi / question / answer / status / unblock. \"\n f\"(Adding new kinds requires gateway-side support; check \"\n f\"server.py POST /messages handler.)\"\n )\n\n # Recipient validation — FAIL-CLOSED (strict=True). A send is about to\n # hit the same gateway via POST /messages, so the registry's /peers is\n # reachable in the normal case (and a warm TTL cache covers transient\n # blips). strict=False used to fail OPEN: a /peers hiccup skipped the\n # registry check and let a regex-valid-but-unregistered name through,\n # POSTing the DM into the void (adversarial-sweep auth-bypass HIGH).\n # Pass this client's own gateway_url + token so the registry check hits\n # the SAME gateway we POST to (not the MESH_GATEWAY_URL env default).\n canonical_to = validate_node_name(\n to, strict=True, gateway_url=self._gateway_url, token=self._token,\n )\n\n # Best-effort credential leak guard. Operator can disable for\n # legitimate prose mentioning credential shapes.\n if not skip_secret_check:\n _check_no_secret(content)\n\n body: dict[str, Any] = {\n \"from_node\": self.node,\n \"to_node\": canonical_to,\n \"kind\": kind,\n \"content\": content,\n }\n if related_task_id is not None:\n body[\"related_task_id\"] = related_task_id\n if thread_id is not None:\n body[\"thread_id\"] = thread_id\n\n payload = await self._request(\"POST\", \"/messages\", json=body)\n return MeshMessage.model_validate(payload)\n\n async def mark_read(self, msg_id: int) -> None:\n \"\"\"``POST /messages/{msg_id}/read`` — flip ``read_at`` on a DM.\n\n Per CLAUDE.md droplet-side mesh DM drain rule, the cron-side\n watcher does NOT mark read; only the commander or the\n peer-in-session does. Adapter callers consuming inboxes\n should mark-read explicitly when they've actually processed\n the DM, not on fetch.\n \"\"\"\n await self._request(\"POST\", f\"/messages/{msg_id}/read\")\n"}, "507df6f35376b3e972de010fff789c237de98c63": {"t": 1785773554.1868844, "n": 17972, "h": "5175cd0d37232d8e", "c": "\"\"\"``MeshClient`` — async wrapper around the mesh-gateway HTTP API per\nPLAN.md §5.\n\nReplaces the hand-rolled curl-based code in:\n\n* ``lab-orchestrator/scripts/lab_loop_drain.py``\n* ``hedge-fund-mcp/scripts/mesh_inbox_watcher.py`` (droplet-side)\n* ``hedge-fund-mcp/scripts/science_claude_inbox_drain.py``\n* assorted ad-hoc curl invocations in CLAUDE.md / session ritual\n\nwith a single typed surface so cross-Claude DM coordination doesn't\nrequire re-discovering the gateway shape from scratch every time.\n\nPublic surface:\n\n client = MeshClient(node=\"lab-ovh\", token=os.environ[\"MESH_GATEWAY_TOKEN\"])\n peers = await client.list_peers()\n msgs = await client.fetch(unread_only=True)\n sent = await client.send(to=\"droplet\", kind=\"fyi\", content=\"...\")\n await client.mark_read(msg_id=123)\n await client.register(url=\"http://lab-ovh:8787\", capabilities={...})\n\nTwo structural invariants enforced:\n\n1. **Recipient name validation.** Every ``send()`` calls\n ``swarph_shared.validate_node_name`` on the recipient — closes the\n Vector A (peer-onboarding chatter) + Vector B (human-prompt\n shorthand) framing-contagion classes documented in\n ``project_peer_name_canonical.md``.\n\n2. **Mesh-secrets out-of-band guard.** Every ``send()`` runs a\n best-effort regex sniff on content for obvious credentials\n (``pypi-...``, ``sk-...``, ``gh[ops]_...``, ``eyJ...`` JWTs).\n Hits raise :class:`MeshSecretLeakError` BEFORE the POST. CLAUDE.md\n \"Mesh secrets out-of-band only — NEVER ride /messages content\n fields\" is the non-negotiable rule. Best-effort because regex\n misses are common — operators must still treat content fields as\n public. The guard catches the obvious cases.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport re\nfrom typing import Any, Literal, Optional\n\n\n# v0.5.1 (drop DM #722 + #728): client-side enum for mesh-gateway-accepted\n# DM kinds. Gateway validates server-side too; this is fail-fast at the\n# type-check + runtime layer so callers don't round-trip a known-bad value\n# (e.g., kind=\"review_request\" → 400). Same shape as the validate_caller\n# pattern from swarph_shared. Add new kinds here when the gateway ships\n# them (current accepted set per server.py mesh-gateway):\nDM_KIND = Literal[\"fyi\", \"question\", \"answer\", \"status\", \"unblock\"]\n\nimport httpx\nfrom swarph_shared import validate_node_name\n\nfrom swarph_mesh.exceptions import SwarphMeshError\nfrom swarph_mesh.mesh_types import MeshMessage, MeshPeer\n\n\nDEFAULT_GATEWAY_URL = \"http://lab-ovh:8788\"\nGATEWAY_TOKEN_ENV = \"MESH_GATEWAY_TOKEN\"\nDEFAULT_TIMEOUT_SECONDS = 10.0\n\n\n# ---------------------------------------------------------------------------\n# Exceptions\n# ---------------------------------------------------------------------------\n\n\nclass MeshGatewayError(SwarphMeshError):\n \"\"\"Gateway returned non-2xx or otherwise non-conformant response.\"\"\"\n\n\nclass MeshAuthError(MeshGatewayError):\n \"\"\"Authentication failed — token missing or rejected (401/403).\"\"\"\n\n\nclass MeshSecretLeakError(SwarphMeshError):\n \"\"\"Best-effort guard caught an apparent credential in DM content.\n\n Refuses to send. Operators must rotate the credential and resend\n without it.\n \"\"\"\n\n\n# ---------------------------------------------------------------------------\n# Best-effort credential leak detector\n# ---------------------------------------------------------------------------\n\n# Patterns chosen to match observable shapes of credentials seen in\n# session JSONLs in the field. Updates here are cheap; misses are\n# expected (defense-in-depth, not strict validation).\n_CRED_PATTERNS: list[tuple[str, re.Pattern[str]]] = [\n (\"pypi token\", re.compile(r\"\\bpypi-[A-Za-z0-9_-]{20,}\")),\n (\"anthropic api key\", re.compile(r\"\\bsk-ant-[A-Za-z0-9_-]{20,}\")),\n # OpenAI ADMIN keys (highest privilege class — can create/delete keys,\n # manage org). Listed FIRST so it matches before the generic openai\n # pattern, giving the operator a properly scary error message.\n # Endpoint that mints these: POST /v1/organization/admin_api_keys.\n (\"openai ADMIN key (org-level privilege)\", re.compile(r\"\\bsk-admin-[A-Za-z0-9_-]{20,}\")),\n # OpenAI key shapes: legacy ``sk-<48hex>`` + project keys\n # ``sk-proj-<long>`` (newer, longer alphabet incl. ``_-``). Earlier\n # regex ``sk-[A-Za-z0-9]{20,}`` missed sk-proj-... because of the\n # underscore + hyphen in the body. Issue #4 tightening.\n # NOTE: ``sk-admin-...`` is matched by the named pattern above so\n # this catch-all doesn't downgrade an admin key's error severity.\n (\"openai api key\", re.compile(r\"\\bsk-(?!admin-)(?:proj-)?[A-Za-z0-9_-]{20,}\")),\n # xAI / Grok key — xai-<long>. Same shape as OpenAI's hyphen-prefix\n # pattern. Added in v0.5.1 alongside the Grok adapter shipped in\n # v0.5.0; would have matched the key the user pasted in chat earlier\n # this session if mesh send had been the destination.\n (\"xai api key\", re.compile(r\"\\bxai-[A-Za-z0-9_-]{40,}\")),\n # DeepSeek api key — sk-<32hex> hex-only post the sk- prefix.\n # Already covered loosely by the openai pattern but listing\n # explicitly so the error message names the right provider.\n (\"deepseek api key\", re.compile(r\"\\bsk-[a-f0-9]{32}\\b\")),\n # Google Gemini / Cloud API keys — ``AIza<35-alnum>`` format.\n # Common shape across Google's API tier; would catch GEMINI_API_KEY\n # leaks. Issue #4 tightening (was missing).\n (\"google api key\", re.compile(r\"\\bAIza[0-9A-Za-z_-]{35}\")),\n (\"github token\", re.compile(r\"\\bgh[ops]_[A-Za-z0-9]{30,}\")),\n (\"aws access key\", re.compile(r\"\\bAKIA[0-9A-Z]{16}\\b\")),\n (\"aws secret key\", re.compile(r\"\\b[A-Za-z0-9/+=]{40}\\b(?=.*aws)\")), # heuristic\n # JWT — three base64url segments separated by dots (stricter than the\n # generic shape so we don't false-fire on every blob with two dots).\n (\"jwt\", re.compile(r\"\\beyJ[A-Za-z0-9_-]{15,}\\.eyJ[A-Za-z0-9_-]{15,}\\.[A-Za-z0-9_-]{15,}\\b\")),\n # Mesh gateway tokens — long random base64url-shaped values stored in\n # MESH_GATEWAY_TOKEN env. Heuristic shape: 40+ chars of url-safe\n # base64 alphabet. False-positive rate higher than other patterns —\n # operator escape via skip_secret_check=True is the right hatch.\n (\"possible mesh-gateway token\", re.compile(r\"\\b[A-Za-z0-9_-]{48,}\\b(?=.*MESH_GATEWAY_TOKEN)\")),\n]\n\n\ndef _check_no_secret(content: str) -> None:\n \"\"\"Raise :class:`MeshSecretLeakError` if content looks like it\n carries a credential. Best-effort; expected to miss novel shapes.\n \"\"\"\n for label, rx in _CRED_PATTERNS:\n if rx.search(content):\n raise MeshSecretLeakError(\n f\"refusing to send: content matches {label} pattern. \"\n \"Mesh secrets out-of-band only — NEVER ride /messages \"\n \"content fields. Use ssh+tee, commander manual paste, \"\n \"or pre-redacted Hub records. (CLAUDE.md mesh-secrets rule.)\"\n )\n\n\n# ---------------------------------------------------------------------------\n# MeshClient\n# ---------------------------------------------------------------------------\n\n\nclass MeshClient:\n \"\"\"Async client for the mesh-gateway HTTP API.\n\n Construct once per peer (or per cycle), reuse for the lifetime of\n the work. Backed by ``httpx.AsyncClient`` so connection-pooling\n happens for free across many ``send()`` calls.\n\n Use as an async context manager when you want explicit close:\n\n async with MeshClient(node=\"lab-ovh\") as client:\n await client.send(to=\"droplet\", kind=\"fyi\", content=\"...\")\n\n Or instantiate normally + call ``aclose()`` when done.\n \"\"\"\n\n def __init__(\n self,\n node: str,\n *,\n token: Optional[str] = None,\n gateway_url: Optional[str] = None,\n timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,\n validate_self_name: bool = True,\n ):\n \"\"\"``token`` falls back to ``MESH_GATEWAY_TOKEN`` env. ``gateway_url``\n falls back to ``MESH_GATEWAY_URL`` env, then ``http://lab-ovh:8788``.\n\n Set ``validate_self_name=False`` for test fixtures that need to\n instantiate a client with a mock peer name. Production callers\n should leave it on (caller's own ``node`` is the most common\n contagion target — see project_peer_name_canonical.md).\n \"\"\"\n # Self-name validation — best-effort. If the gateway is offline\n # we still want construction to succeed; the validation is\n # registry-dependent so use strict=False to skip the registry\n # check when offline.\n if validate_self_name:\n try:\n node = validate_node_name(node, strict=False)\n except Exception: # pragma: no cover — defensive\n pass\n\n self.node = node\n self._token = token if token is not None else os.environ.get(GATEWAY_TOKEN_ENV)\n self._gateway_url = (\n gateway_url\n or os.environ.get(\"MESH_GATEWAY_URL\")\n or DEFAULT_GATEWAY_URL\n )\n self._timeout = timeout_seconds\n self._client: Optional[httpx.AsyncClient] = None\n\n # ------------------------------------------------------------------\n # Lifecycle\n # ------------------------------------------------------------------\n\n def _ensure_client(self) -> httpx.AsyncClient:\n if self._client is None or self._client.is_closed:\n headers: dict[str, str] = {}\n if self._token:\n headers[\"Authorization\"] = f\"Bearer {self._token}\"\n self._client = httpx.AsyncClient(\n base_url=self._gateway_url,\n headers=headers,\n timeout=self._timeout,\n )\n return self._client\n\n async def aclose(self) -> None:\n if self._client is not None and not self._client.is_closed:\n await self._client.aclose()\n self._client = None\n\n async def __aenter__(self) -> \"MeshClient\":\n self._ensure_client()\n return self\n\n async def __aexit__(self, exc_type, exc, tb) -> None:\n await self.aclose()\n\n # ------------------------------------------------------------------\n # Internal HTTP wrapper\n # ------------------------------------------------------------------\n\n async def _request(\n self,\n method: str,\n path: str,\n *,\n params: Optional[dict[str, Any]] = None,\n json: Optional[dict[str, Any]] = None,\n ) -> Any:\n client = self._ensure_client()\n try:\n resp = await client.request(method, path, params=params, json=json)\n except httpx.RequestError as exc:\n raise MeshGatewayError(\n f\"mesh-gateway {method} {path} request failed: {exc}\"\n ) from exc\n\n if resp.status_code == 401 or resp.status_code == 403:\n raise MeshAuthError(\n f\"mesh-gateway {method} {path} returned {resp.status_code}: \"\n f\"check MESH_GATEWAY_TOKEN env\"\n )\n if resp.status_code >= 400:\n body = resp.text[:500]\n raise MeshGatewayError(\n f\"mesh-gateway {method} {path} returned {resp.status_code}: {body}\"\n )\n\n # Gateway returns JSON for everything except /health\n if not resp.content:\n return None\n try:\n return resp.json()\n except ValueError as exc:\n raise MeshGatewayError(\n f\"mesh-gateway {method} {path} returned non-JSON: {resp.text[:200]}\"\n ) from exc\n\n # ------------------------------------------------------------------\n # Peer endpoints\n # ------------------------------------------------------------------\n\n async def list_peers(self) -> list[MeshPeer]:\n \"\"\"``GET /peers`` — return all registered peers.\"\"\"\n payload = await self._request(\"GET\", \"/peers\")\n rows = payload.get(\"peers\", payload) if isinstance(payload, dict) else payload\n return [MeshPeer.model_validate(r) for r in rows]\n\n async def register(\n self,\n *,\n url: Optional[str] = None,\n capabilities: Optional[dict[str, Any]] = None,\n ) -> MeshPeer:\n \"\"\"``POST /peers/register`` — register this peer with the gateway.\"\"\"\n body: dict[str, Any] = {\"name\": self.node}\n if url is not None:\n body[\"url\"] = url\n if capabilities is not None:\n body[\"capabilities\"] = capabilities\n payload = await self._request(\"POST\", \"/peers/register\", json=body)\n return MeshPeer.model_validate(payload)\n\n # ------------------------------------------------------------------\n # Message endpoints\n # ------------------------------------------------------------------\n\n async def fetch(\n self,\n *,\n to_node: Optional[str] = None,\n unread_only: bool = False,\n limit: Optional[int] = None,\n ) -> list[MeshMessage]:\n \"\"\"``GET /messages`` — fetch own inbox.\n\n Defaults ``to_node`` to ``self.node``. Pass an explicit value\n only if you have a legitimate reason to peek at another peer's\n inbox (gateway access-controls this; most callers will get\n their own inbox only).\n\n v0.5.1 latent bug fix (discovered in Phase 5.6 daemon build):\n the mesh-gateway query parameter is ``?to=`` — NOT ``?to_node=``.\n The latter is silently ignored, returning ALL recent messages\n regardless of recipient. Pre-fix every ``MeshClient.fetch``\n caller saw outbound bleed-through unless they Python-side\n filtered. The kwarg keeps its descriptive name for the\n caller-facing API; only the wire-shape changes.\n \"\"\"\n # Defense-in-depth: filter from_node!=self_node client-side too,\n # so any future gateway quirk that re-introduces outbound\n # bleed-through doesn't silently break consumers (same shape\n # as the daemon's filter).\n target = to_node or self.node\n params: dict[str, Any] = {\"to\": target}\n if unread_only:\n params[\"unread_only\"] = \"true\"\n if limit is not None:\n params[\"limit\"] = limit\n payload = await self._request(\"GET\", \"/messages\", params=params)\n rows = payload.get(\"messages\", payload) if isinstance(payload, dict) else payload\n # Client-side defensive filter — only if fetching own inbox\n # (i.e., target == self.node). When peeking at another peer's\n # inbox via explicit to_node= override, return whatever gateway\n # returns without filtering (caller asked for raw view).\n if target == self.node:\n rows = [r for r in rows if r.get(\"from_node\") != self.node]\n return [MeshMessage.model_validate(r) for r in rows]\n\n async def send(\n self,\n *,\n to: str,\n kind: DM_KIND,\n content: str,\n related_task_id: Optional[str] = None,\n thread_id: Optional[str] = None,\n skip_secret_check: bool = False,\n ) -> MeshMessage:\n \"\"\"``POST /messages`` — send a DM.\n\n Validates ``to`` via ``swarph_shared.validate_node_name`` (closes\n Vector A + B contagion classes); raises ``ValueError`` /\n ``NotInRegistry`` on invalid recipient names BEFORE the POST.\n\n ``kind`` is a ``Literal`` enum (see ``DM_KIND`` above) — type\n checkers reject unknown values at lint time, and the runtime\n guard below catches anyone passing ``kind`` as ``str``-typed\n from a callsite that bypassed type-checking.\n\n Runs the best-effort credential leak detector on ``content``.\n Set ``skip_secret_check=True`` only when content has been\n inspected by the caller and the leak detector is producing a\n false positive (e.g. discussing a credential format in\n prose). False positives should be rare; treat them as a\n signal that the content is on a fence and rotate aggressively\n if you're not 100% sure.\n \"\"\"\n # Runtime kind enum check — defense-in-depth against str-typed\n # callsites that slipped past type-checking. Same shape as\n # validate_caller fail-fast discipline.\n if kind not in {\"fyi\", \"question\", \"answer\", \"status\", \"unblock\"}:\n raise ValueError(\n f\"MeshClient.send: kind={kind!r} is not a valid mesh-gateway \"\n f\"DM kind. Accepted: fyi / question / answer / status / unblock. \"\n f\"(Adding new kinds requires gateway-side support; check \"\n f\"server.py POST /messages handler.)\"\n )\n\n # Recipient validation — strict=False because the gateway may\n # not be reachable from caller's perspective at the moment;\n # we still want regex + alias resolution.\n canonical_to = validate_node_name(to, strict=False)\n\n # Best-effort credential leak guard. Operator can disable for\n # legitimate prose mentioning credential shapes.\n if not skip_secret_check:\n _check_no_secret(content)\n\n body: dict[str, Any] = {\n \"from_node\": self.node,\n \"to_node\": canonical_to,\n \"kind\": kind,\n \"content\": content,\n }\n if related_task_id is not None:\n body[\"related_task_id\"] = related_task_id\n if thread_id is not None:\n body[\"thread_id\"] = thread_id\n\n payload = await self._request(\"POST\", \"/messages\", json=body)\n return MeshMessage.model_validate(payload)\n\n async def mark_read(self, msg_id: int) -> None:\n \"\"\"``POST /messages/{msg_id}/read`` — flip ``read_at`` on a DM.\n\n Per CLAUDE.md droplet-side mesh DM drain rule, the cron-side\n watcher does NOT mark read; only the commander or the\n peer-in-session does. Adapter callers consuming inboxes\n should mark-read explicitly when they've actually processed\n the DM, not on fetch.\n \"\"\"\n await self._request(\"POST\", f\"/messages/{msg_id}/read\")\n"}, "21b1e3de1a3bb825e44c89c8b2a6078247194b4b": {"t": 1785773555.5604951, "n": 17443, "h": "f0d154771806dbad", "c": "\"\"\"``MeshClient`` — async wrapper around the mesh-gateway HTTP API per\nPLAN.md §5.\n\nReplaces the hand-rolled curl-based code in:\n\n* ``lab-orchestrator/scripts/lab_loop_drain.py``\n* ``hedge-fund-mcp/scripts/mesh_inbox_watcher.py`` (droplet-side)\n* ``hedge-fund-mcp/scripts/science_claude_inbox_drain.py``\n* assorted ad-hoc curl invocations in CLAUDE.md / session ritual\n\nwith a single typed surface so cross-Claude DM coordination doesn't\nrequire re-discovering the gateway shape from scratch every time.\n\nPublic surface:\n\n client = MeshClient(node=\"lab-ovh\", token=os.environ[\"MESH_GATEWAY_TOKEN\"])\n peers = await client.list_peers()\n msgs = await client.fetch(unread_only=True)\n sent = await client.send(to=\"droplet\", kind=\"fyi\", content=\"...\")\n await client.mark_read(msg_id=123)\n await client.register(url=\"http://lab-ovh:8787\", capabilities={...})\n\nTwo structural invariants enforced:\n\n1. **Recipient name validation.** Every ``send()`` calls\n ``swarph_shared.validate_node_name`` on the recipient — closes the\n Vector A (peer-onboarding chatter) + Vector B (human-prompt\n shorthand) framing-contagion classes documented in\n ``project_peer_name_canonical.md``.\n\n2. **Mesh-secrets out-of-band guard.** Every ``send()`` runs a\n best-effort regex sniff on content for obvious credentials\n (``pypi-...``, ``sk-...``, ``gh[ops]_...``, ``eyJ...`` JWTs).\n Hits raise :class:`MeshSecretLeakError` BEFORE the POST. CLAUDE.md\n \"Mesh secrets out-of-band only — NEVER ride /messages content\n fields\" is the non-negotiable rule. Best-effort because regex\n misses are common — operators must still treat content fields as\n public. The guard catches the obvious cases.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport re\nfrom typing import Any, Literal, Optional\n\n\n# v0.5.1 (drop DM #722 + #728): client-side enum for mesh-gateway-accepted\n# DM kinds. Gateway validates server-side too; this is fail-fast at the\n# type-check + runtime layer so callers don't round-trip a known-bad value\n# (e.g., kind=\"review_request\" → 400). Same shape as the validate_caller\n# pattern from swarph_shared. Add new kinds here when the gateway ships\n# them (current accepted set per server.py mesh-gateway):\nDM_KIND = Literal[\"fyi\", \"question\", \"answer\", \"status\", \"unblock\"]\n\nimport httpx\nfrom swarph_shared import validate_node_name\n\nfrom swarph_mesh.exceptions import SwarphMeshError\nfrom swarph_mesh.mesh_types import MeshMessage, MeshPeer\n\n\nDEFAULT_GATEWAY_URL = \"http://lab-ovh:8788\"\nGATEWAY_TOKEN_ENV = \"MESH_GATEWAY_TOKEN\"\nDEFAULT_TIMEOUT_SECONDS = 10.0\n\n\n# ---------------------------------------------------------------------------\n# Exceptions\n# ---------------------------------------------------------------------------\n\n\nclass MeshGatewayError(SwarphMeshError):\n \"\"\"Gateway returned non-2xx or otherwise non-conformant response.\"\"\"\n\n\nclass MeshAuthError(MeshGatewayError):\n \"\"\"Authentication failed — token missing or rejected (401/403).\"\"\"\n\n\nclass MeshSecretLeakError(SwarphMeshError):\n \"\"\"Best-effort guard caught an apparent credential in DM content.\n\n Refuses to send. Operators must rotate the credential and resend\n without it.\n \"\"\"\n\n\n# ---------------------------------------------------------------------------\n# Best-effort credential leak detector\n# ---------------------------------------------------------------------------\n\n# Patterns chosen to match observable shapes of credentials seen in\n# session JSONLs in the field. Updates here are cheap; misses are\n# expected (defense-in-depth, not strict validation).\n_CRED_PATTERNS: list[tuple[str, re.Pattern[str]]] = [\n (\"pypi token\", re.compile(r\"\\bpypi-[A-Za-z0-9_-]{20,}\")),\n (\"anthropic api key\", re.compile(r\"\\bsk-ant-[A-Za-z0-9_-]{20,}\")),\n # OpenAI key shapes: legacy ``sk-<48hex>`` + project keys\n # ``sk-proj-<long>`` (newer, longer alphabet incl. ``_-``). Earlier\n # regex ``sk-[A-Za-z0-9]{20,}`` missed sk-proj-... because of the\n # underscore + hyphen in the body. Issue #4 tightening.\n (\"openai api key\", re.compile(r\"\\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\")),\n # xAI / Grok key — xai-<long>. Same shape as OpenAI's hyphen-prefix\n # pattern. Added in v0.5.1 alongside the Grok adapter shipped in\n # v0.5.0; would have matched the key the user pasted in chat earlier\n # this session if mesh send had been the destination.\n (\"xai api key\", re.compile(r\"\\bxai-[A-Za-z0-9_-]{40,}\")),\n # DeepSeek api key — sk-<32hex> hex-only post the sk- prefix.\n # Already covered loosely by the openai pattern but listing\n # explicitly so the error message names the right provider.\n (\"deepseek api key\", re.compile(r\"\\bsk-[a-f0-9]{32}\\b\")),\n # Google Gemini / Cloud API keys — ``AIza<35-alnum>`` format.\n # Common shape across Google's API tier; would catch GEMINI_API_KEY\n # leaks. Issue #4 tightening (was missing).\n (\"google api key\", re.compile(r\"\\bAIza[0-9A-Za-z_-]{35}\")),\n (\"github token\", re.compile(r\"\\bgh[ops]_[A-Za-z0-9]{30,}\")),\n (\"aws access key\", re.compile(r\"\\bAKIA[0-9A-Z]{16}\\b\")),\n (\"aws secret key\", re.compile(r\"\\b[A-Za-z0-9/+=]{40}\\b(?=.*aws)\")), # heuristic\n # JWT — three base64url segments separated by dots (stricter than the\n # generic shape so we don't false-fire on every blob with two dots).\n (\"jwt\", re.compile(r\"\\beyJ[A-Za-z0-9_-]{15,}\\.eyJ[A-Za-z0-9_-]{15,}\\.[A-Za-z0-9_-]{15,}\\b\")),\n # Mesh gateway tokens — long random base64url-shaped values stored in\n # MESH_GATEWAY_TOKEN env. Heuristic shape: 40+ chars of url-safe\n # base64 alphabet. False-positive rate higher than other patterns —\n # operator escape via skip_secret_check=True is the right hatch.\n (\"possible mesh-gateway token\", re.compile(r\"\\b[A-Za-z0-9_-]{48,}\\b(?=.*MESH_GATEWAY_TOKEN)\")),\n]\n\n\ndef _check_no_secret(content: str) -> None:\n \"\"\"Raise :class:`MeshSecretLeakError` if content looks like it\n carries a credential. Best-effort; expected to miss novel shapes.\n \"\"\"\n for label, rx in _CRED_PATTERNS:\n if rx.search(content):\n raise MeshSecretLeakError(\n f\"refusing to send: content matches {label} pattern. \"\n \"Mesh secrets out-of-band only — NEVER ride /messages \"\n \"content fields. Use ssh+tee, commander manual paste, \"\n \"or pre-redacted Hub records. (CLAUDE.md mesh-secrets rule.)\"\n )\n\n\n# ---------------------------------------------------------------------------\n# MeshClient\n# ---------------------------------------------------------------------------\n\n\nclass MeshClient:\n \"\"\"Async client for the mesh-gateway HTTP API.\n\n Construct once per peer (or per cycle), reuse for the lifetime of\n the work. Backed by ``httpx.AsyncClient`` so connection-pooling\n happens for free across many ``send()`` calls.\n\n Use as an async context manager when you want explicit close:\n\n async with MeshClient(node=\"lab-ovh\") as client:\n await client.send(to=\"droplet\", kind=\"fyi\", content=\"...\")\n\n Or instantiate normally + call ``aclose()`` when done.\n \"\"\"\n\n def __init__(\n self,\n node: str,\n *,\n token: Optional[str] = None,\n gateway_url: Optional[str] = None,\n timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,\n validate_self_name: bool = True,\n ):\n \"\"\"``token`` falls back to ``MESH_GATEWAY_TOKEN`` env. ``gateway_url``\n falls back to ``MESH_GATEWAY_URL`` env, then ``http://lab-ovh:8788``.\n\n Set ``validate_self_name=False`` for test fixtures that need to\n instantiate a client with a mock peer name. Production callers\n should leave it on (caller's own ``node`` is the most common\n contagion target — see project_peer_name_canonical.md).\n \"\"\"\n # Self-name validation — best-effort. If the gateway is offline\n # we still want construction to succeed; the validation is\n # registry-dependent so use strict=False to skip the registry\n # check when offline.\n if validate_self_name:\n try:\n node = validate_node_name(node, strict=False)\n except Exception: # pragma: no cover — defensive\n pass\n\n self.node = node\n self._token = token if token is not None else os.environ.get(GATEWAY_TOKEN_ENV)\n self._gateway_url = (\n gateway_url\n or os.environ.get(\"MESH_GATEWAY_URL\")\n or DEFAULT_GATEWAY_URL\n )\n self._timeout = timeout_seconds\n self._client: Optional[httpx.AsyncClient] = None\n\n # ------------------------------------------------------------------\n # Lifecycle\n # ------------------------------------------------------------------\n\n def _ensure_client(self) -> httpx.AsyncClient:\n if self._client is None or self._client.is_closed:\n headers: dict[str, str] = {}\n if self._token:\n headers[\"Authorization\"] = f\"Bearer {self._token}\"\n self._client = httpx.AsyncClient(\n base_url=self._gateway_url,\n headers=headers,\n timeout=self._timeout,\n )\n return self._client\n\n async def aclose(self) -> None:\n if self._client is not None and not self._client.is_closed:\n await self._client.aclose()\n self._client = None\n\n async def __aenter__(self) -> \"MeshClient\":\n self._ensure_client()\n return self\n\n async def __aexit__(self, exc_type, exc, tb) -> None:\n await self.aclose()\n\n # ------------------------------------------------------------------\n # Internal HTTP wrapper\n # ------------------------------------------------------------------\n\n async def _request(\n self,\n method: str,\n path: str,\n *,\n params: Optional[dict[str, Any]] = None,\n json: Optional[dict[str, Any]] = None,\n ) -> Any:\n client = self._ensure_client()\n try:\n resp = await client.request(method, path, params=params, json=json)\n except httpx.RequestError as exc:\n raise MeshGatewayError(\n f\"mesh-gateway {method} {path} request failed: {exc}\"\n ) from exc\n\n if resp.status_code == 401 or resp.status_code == 403:\n raise MeshAuthError(\n f\"mesh-gateway {method} {path} returned {resp.status_code}: \"\n f\"check MESH_GATEWAY_TOKEN env\"\n )\n if resp.status_code >= 400:\n body = resp.text[:500]\n raise MeshGatewayError(\n f\"mesh-gateway {method} {path} returned {resp.status_code}: {body}\"\n )\n\n # Gateway returns JSON for everything except /health\n if not resp.content:\n return None\n try:\n return resp.json()\n except ValueError as exc:\n raise MeshGatewayError(\n f\"mesh-gateway {method} {path} returned non-JSON: {resp.text[:200]}\"\n ) from exc\n\n # ------------------------------------------------------------------\n # Peer endpoints\n # ------------------------------------------------------------------\n\n async def list_peers(self) -> list[MeshPeer]:\n \"\"\"``GET /peers`` — return all registered peers.\"\"\"\n payload = await self._request(\"GET\", \"/peers\")\n rows = payload.get(\"peers\", payload) if isinstance(payload, dict) else payload\n return [MeshPeer.model_validate(r) for r in rows]\n\n async def register(\n self,\n *,\n url: Optional[str] = None,\n capabilities: Optional[dict[str, Any]] = None,\n ) -> MeshPeer:\n \"\"\"``POST /peers/register`` — register this peer with the gateway.\"\"\"\n body: dict[str, Any] = {\"name\": self.node}\n if url is not None:\n body[\"url\"] = url\n if capabilities is not None:\n body[\"capabilities\"] = capabilities\n payload = await self._request(\"POST\", \"/peers/register\", json=body)\n return MeshPeer.model_validate(payload)\n\n # ------------------------------------------------------------------\n # Message endpoints\n # ------------------------------------------------------------------\n\n async def fetch(\n self,\n *,\n to_node: Optional[str] = None,\n unread_only: bool = False,\n limit: Optional[int] = None,\n ) -> list[MeshMessage]:\n \"\"\"``GET /messages`` — fetch own inbox.\n\n Defaults ``to_node`` to ``self.node``. Pass an explicit value\n only if you have a legitimate reason to peek at another peer's\n inbox (gateway access-controls this; most callers will get\n their own inbox only).\n\n v0.5.1 latent bug fix (discovered in Phase 5.6 daemon build):\n the mesh-gateway query parameter is ``?to=`` — NOT ``?to_node=``.\n The latter is silently ignored, returning ALL recent messages\n regardless of recipient. Pre-fix every ``MeshClient.fetch``\n caller saw outbound bleed-through unless they Python-side\n filtered. The kwarg keeps its descriptive name for the\n caller-facing API; only the wire-shape changes.\n \"\"\"\n # Defense-in-depth: filter from_node!=self_node client-side too,\n # so any future gateway quirk that re-introduces outbound\n # bleed-through doesn't silently break consumers (same shape\n # as the daemon's filter).\n target = to_node or self.node\n params: dict[str, Any] = {\"to\": target}\n if unread_only:\n params[\"unread_only\"] = \"true\"\n if limit is not None:\n params[\"limit\"] = limit\n payload = await self._request(\"GET\", \"/messages\", params=params)\n rows = payload.get(\"messages\", payload) if isinstance(payload, dict) else payload\n # Client-side defensive filter — only if fetching own inbox\n # (i.e., target == self.node). When peeking at another peer's\n # inbox via explicit to_node= override, return whatever gateway\n # returns without filtering (caller asked for raw view).\n if target == self.node:\n rows = [r for r in rows if r.get(\"from_node\") != self.node]\n return [MeshMessage.model_validate(r) for r in rows]\n\n async def send(\n self,\n *,\n to: str,\n kind: DM_KIND,\n content: str,\n related_task_id: Optional[str] = None,\n thread_id: Optional[str] = None,\n skip_secret_check: bool = False,\n ) -> MeshMessage:\n \"\"\"``POST /messages`` — send a DM.\n\n Validates ``to`` via ``swarph_shared.validate_node_name`` (closes\n Vector A + B contagion classes); raises ``ValueError`` /\n ``NotInRegistry`` on invalid recipient names BEFORE the POST.\n\n ``kind`` is a ``Literal`` enum (see ``DM_KIND`` above) — type\n checkers reject unknown values at lint time, and the runtime\n guard below catches anyone passing ``kind`` as ``str``-typed\n from a callsite that bypassed type-checking.\n\n Runs the best-effort credential leak detector on ``content``.\n Set ``skip_secret_check=True`` only when content has been\n inspected by the caller and the leak detector is producing a\n false positive (e.g. discussing a credential format in\n prose). False positives should be rare; treat them as a\n signal that the content is on a fence and rotate aggressively\n if you're not 100% sure.\n \"\"\"\n # Runtime kind enum check — defense-in-depth against str-typed\n # callsites that slipped past type-checking. Same shape as\n # validate_caller fail-fast discipline.\n if kind not in {\"fyi\", \"question\", \"answer\", \"status\", \"unblock\"}:\n raise ValueError(\n f\"MeshClient.send: kind={kind!r} is not a valid mesh-gateway \"\n f\"DM kind. Accepted: fyi / question / answer / status / unblock. \"\n f\"(Adding new kinds requires gateway-side support; check \"\n f\"server.py POST /messages handler.)\"\n )\n\n # Recipient validation — strict=False because the gateway may\n # not be reachable from caller's perspective at the moment;\n # we still want regex + alias resolution.\n canonical_to = validate_node_name(to, strict=False)\n\n # Best-effort credential leak guard. Operator can disable for\n # legitimate prose mentioning credential shapes.\n if not skip_secret_check:\n _check_no_secret(content)\n\n body: dict[str, Any] = {\n \"from_node\": self.node,\n \"to_node\": canonical_to,\n \"kind\": kind,\n \"content\": content,\n }\n if related_task_id is not None:\n body[\"related_task_id\"] = related_task_id\n if thread_id is not None:\n body[\"thread_id\"] = thread_id\n\n payload = await self._request(\"POST\", \"/messages\", json=body)\n return MeshMessage.model_validate(payload)\n\n async def mark_read(self, msg_id: int) -> None:\n \"\"\"``POST /messages/{msg_id}/read`` — flip ``read_at`` on a DM.\n\n Per CLAUDE.md droplet-side mesh DM drain rule, the cron-side\n watcher does NOT mark read; only the commander or the\n peer-in-session does. Adapter callers consuming inboxes\n should mark-read explicitly when they've actually processed\n the DM, not on fetch.\n \"\"\"\n await self._request(\"POST\", f\"/messages/{msg_id}/read\")\n"}, "83a55946cd123313db0437a741072257654c988c": {"t": 1785773557.0142937, "n": 13467, "h": "92813c4905315958", "c": "\"\"\"``MeshClient`` — async wrapper around the mesh-gateway HTTP API per\nPLAN.md §5.\n\nReplaces the hand-rolled curl-based code in:\n\n* ``lab-orchestrator/scripts/lab_loop_drain.py``\n* ``hedge-fund-mcp/scripts/mesh_inbox_watcher.py`` (droplet-side)\n* ``hedge-fund-mcp/scripts/science_claude_inbox_drain.py``\n* assorted ad-hoc curl invocations in CLAUDE.md / session ritual\n\nwith a single typed surface so cross-Claude DM coordination doesn't\nrequire re-discovering the gateway shape from scratch every time.\n\nPublic surface:\n\n client = MeshClient(node=\"lab-ovh\", token=os.environ[\"MESH_GATEWAY_TOKEN\"])\n peers = await client.list_peers()\n msgs = await client.fetch(unread_only=True)\n sent = await client.send(to=\"droplet\", kind=\"fyi\", content=\"...\")\n await client.mark_read(msg_id=123)\n await client.register(url=\"http://lab-ovh:8787\", capabilities={...})\n\nTwo structural invariants enforced:\n\n1. **Recipient name validation.** Every ``send()`` calls\n ``swarph_shared.validate_node_name`` on the recipient — closes the\n Vector A (peer-onboarding chatter) + Vector B (human-prompt\n shorthand) framing-contagion classes documented in\n ``project_peer_name_canonical.md``.\n\n2. **Mesh-secrets out-of-band guard.** Every ``send()`` runs a\n best-effort regex sniff on content for obvious credentials\n (``pypi-...``, ``sk-...``, ``gh[ops]_...``, ``eyJ...`` JWTs).\n Hits raise :class:`MeshSecretLeakError` BEFORE the POST. CLAUDE.md\n \"Mesh secrets out-of-band only — NEVER ride /messages content\n fields\" is the non-negotiable rule. Best-effort because regex\n misses are common — operators must still treat content fields as\n public. The guard catches the obvious cases.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport re\nfrom typing import Any, Optional\n\nimport httpx\nfrom swarph_shared import validate_node_name\n\nfrom swarph_mesh.exceptions import SwarphMeshError\nfrom swarph_mesh.mesh_types import MeshMessage, MeshPeer\n\n\nDEFAULT_GATEWAY_URL = \"http://lab-ovh:8788\"\nGATEWAY_TOKEN_ENV = \"MESH_GATEWAY_TOKEN\"\nDEFAULT_TIMEOUT_SECONDS = 10.0\n\n\n# ---------------------------------------------------------------------------\n# Exceptions\n# ---------------------------------------------------------------------------\n\n\nclass MeshGatewayError(SwarphMeshError):\n \"\"\"Gateway returned non-2xx or otherwise non-conformant response.\"\"\"\n\n\nclass MeshAuthError(MeshGatewayError):\n \"\"\"Authentication failed — token missing or rejected (401/403).\"\"\"\n\n\nclass MeshSecretLeakError(SwarphMeshError):\n \"\"\"Best-effort guard caught an apparent credential in DM content.\n\n Refuses to send. Operators must rotate the credential and resend\n without it.\n \"\"\"\n\n\n# ---------------------------------------------------------------------------\n# Best-effort credential leak detector\n# ---------------------------------------------------------------------------\n\n# Patterns chosen to match observable shapes of credentials seen in\n# session JSONLs in the field. Updates here are cheap; misses are\n# expected (defense-in-depth, not strict validation).\n_CRED_PATTERNS: list[tuple[str, re.Pattern[str]]] = [\n (\"pypi token\", re.compile(r\"\\bpypi-[A-Za-z0-9_-]{20,}\")),\n (\"anthropic api key\", re.compile(r\"\\bsk-ant-[A-Za-z0-9_-]{20,}\")),\n (\"openai api key\", re.compile(r\"\\bsk-[A-Za-z0-9]{20,}\")),\n (\"github token\", re.compile(r\"\\bgh[ops]_[A-Za-z0-9]{30,}\")),\n (\"aws access key\", re.compile(r\"\\bAKIA[0-9A-Z]{16}\\b\")),\n (\"aws secret key\", re.compile(r\"\\b[A-Za-z0-9/+=]{40}\\b(?=.*aws)\")), # heuristic\n # JWT — three base64url segments separated by dots (stricter than the\n # generic shape so we don't false-fire on every blob with two dots).\n (\"jwt\", re.compile(r\"\\beyJ[A-Za-z0-9_-]{15,}\\.eyJ[A-Za-z0-9_-]{15,}\\.[A-Za-z0-9_-]{15,}\\b\")),\n]\n\n\ndef _check_no_secret(content: str) -> None:\n \"\"\"Raise :class:`MeshSecretLeakError` if content looks like it\n carries a credential. Best-effort; expected to miss novel shapes.\n \"\"\"\n for label, rx in _CRED_PATTERNS:\n if rx.search(content):\n raise MeshSecretLeakError(\n f\"refusing to send: content matches {label} pattern. \"\n \"Mesh secrets out-of-band only — NEVER ride /messages \"\n \"content fields. Use ssh+tee, commander manual paste, \"\n \"or pre-redacted Hub records. (CLAUDE.md mesh-secrets rule.)\"\n )\n\n\n# ---------------------------------------------------------------------------\n# MeshClient\n# ---------------------------------------------------------------------------\n\n\nclass MeshClient:\n \"\"\"Async client for the mesh-gateway HTTP API.\n\n Construct once per peer (or per cycle), reuse for the lifetime of\n the work. Backed by ``httpx.AsyncClient`` so connection-pooling\n happens for free across many ``send()`` calls.\n\n Use as an async context manager when you want explicit close:\n\n async with MeshClient(node=\"lab-ovh\") as client:\n await client.send(to=\"droplet\", kind=\"fyi\", content=\"...\")\n\n Or instantiate normally + call ``aclose()`` when done.\n \"\"\"\n\n def __init__(\n self,\n node: str,\n *,\n token: Optional[str] = None,\n gateway_url: Optional[str] = None,\n timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,\n validate_self_name: bool = True,\n ):\n \"\"\"``token`` falls back to ``MESH_GATEWAY_TOKEN`` env. ``gateway_url``\n falls back to ``MESH_GATEWAY_URL`` env, then ``http://lab-ovh:8788``.\n\n Set ``validate_self_name=False`` for test fixtures that need to\n instantiate a client with a mock peer name. Production callers\n should leave it on (caller's own ``node`` is the most common\n contagion target — see project_peer_name_canonical.md).\n \"\"\"\n # Self-name validation — best-effort. If the gateway is offline\n # we still want construction to succeed; the validation is\n # registry-dependent so use strict=False to skip the registry\n # check when offline.\n if validate_self_name:\n try:\n node = validate_node_name(node, strict=False)\n except Exception: # pragma: no cover — defensive\n pass\n\n self.node = node\n self._token = token if token is not None else os.environ.get(GATEWAY_TOKEN_ENV)\n self._gateway_url = (\n gateway_url\n or os.environ.get(\"MESH_GATEWAY_URL\")\n or DEFAULT_GATEWAY_URL\n )\n self._timeout = timeout_seconds\n self._client: Optional[httpx.AsyncClient] = None\n\n # ------------------------------------------------------------------\n # Lifecycle\n # ------------------------------------------------------------------\n\n def _ensure_client(self) -> httpx.AsyncClient:\n if self._client is None or self._client.is_closed:\n headers: dict[str, str] = {}\n if self._token:\n headers[\"Authorization\"] = f\"Bearer {self._token}\"\n self._client = httpx.AsyncClient(\n base_url=self._gateway_url,\n headers=headers,\n timeout=self._timeout,\n )\n return self._client\n\n async def aclose(self) -> None:\n if self._client is not None and not self._client.is_closed:\n await self._client.aclose()\n self._client = None\n\n async def __aenter__(self) -> \"MeshClient\":\n self._ensure_client()\n return self\n\n async def __aexit__(self, exc_type, exc, tb) -> None:\n await self.aclose()\n\n # ------------------------------------------------------------------\n # Internal HTTP wrapper\n # ------------------------------------------------------------------\n\n async def _request(\n self,\n method: str,\n path: str,\n *,\n params: Optional[dict[str, Any]] = None,\n json: Optional[dict[str, Any]] = None,\n ) -> Any:\n client = self._ensure_client()\n try:\n resp = await client.request(method, path, params=params, json=json)\n except httpx.RequestError as exc:\n raise MeshGatewayError(\n f\"mesh-gateway {method} {path} request failed: {exc}\"\n ) from exc\n\n if resp.status_code == 401 or resp.status_code == 403:\n raise MeshAuthError(\n f\"mesh-gateway {method} {path} returned {resp.status_code}: \"\n f\"check MESH_GATEWAY_TOKEN env\"\n )\n if resp.status_code >= 400:\n body = resp.text[:500]\n raise MeshGatewayError(\n f\"mesh-gateway {method} {path} returned {resp.status_code}: {body}\"\n )\n\n # Gateway returns JSON for everything except /health\n if not resp.content:\n return None\n try:\n return resp.json()\n except ValueError as exc:\n raise MeshGatewayError(\n f\"mesh-gateway {method} {path} returned non-JSON: {resp.text[:200]}\"\n ) from exc\n\n # ------------------------------------------------------------------\n # Peer endpoints\n # ------------------------------------------------------------------\n\n async def list_peers(self) -> list[MeshPeer]:\n \"\"\"``GET /peers`` — return all registered peers.\"\"\"\n payload = await self._request(\"GET\", \"/peers\")\n rows = payload.get(\"peers\", payload) if isinstance(payload, dict) else payload\n return [MeshPeer.model_validate(r) for r in rows]\n\n async def register(\n self,\n *,\n url: Optional[str] = None,\n capabilities: Optional[dict[str, Any]] = None,\n ) -> MeshPeer:\n \"\"\"``POST /peers/register`` — register this peer with the gateway.\"\"\"\n body: dict[str, Any] = {\"name\": self.node}\n if url is not None:\n body[\"url\"] = url\n if capabilities is not None:\n body[\"capabilities\"] = capabilities\n payload = await self._request(\"POST\", \"/peers/register\", json=body)\n return MeshPeer.model_validate(payload)\n\n # ------------------------------------------------------------------\n # Message endpoints\n # ------------------------------------------------------------------\n\n async def fetch(\n self,\n *,\n to_node: Optional[str] = None,\n unread_only: bool = False,\n limit: Optional[int] = None,\n ) -> list[MeshMessage]:\n \"\"\"``GET /messages`` — fetch own inbox.\n\n Defaults ``to_node`` to ``self.node``. Pass an explicit value\n only if you have a legitimate reason to peek at another peer's\n inbox (gateway access-controls this; most callers will get\n their own inbox only).\n \"\"\"\n params: dict[str, Any] = {\"to_node\": to_node or self.node}\n if unread_only:\n params[\"unread_only\"] = \"true\"\n if limit is not None:\n params[\"limit\"] = limit\n payload = await self._request(\"GET\", \"/messages\", params=params)\n rows = payload.get(\"messages\", payload) if isinstance(payload, dict) else payload\n return [MeshMessage.model_validate(r) for r in rows]\n\n async def send(\n self,\n *,\n to: str,\n kind: str,\n content: str,\n related_task_id: Optional[str] = None,\n thread_id: Optional[str] = None,\n skip_secret_check: bool = False,\n ) -> MeshMessage:\n \"\"\"``POST /messages`` — send a DM.\n\n Validates ``to`` via ``swarph_shared.validate_node_name`` (closes\n Vector A + B contagion classes); raises ``ValueError`` /\n ``NotInRegistry`` on invalid recipient names BEFORE the POST.\n\n Runs the best-effort credential leak detector on ``content``.\n Set ``skip_secret_check=True`` only when content has been\n inspected by the caller and the leak detector is producing a\n false positive (e.g. discussing a credential format in\n prose). False positives should be rare; treat them as a\n signal that the content is on a fence and rotate aggressively\n if you're not 100% sure.\n \"\"\"\n # Recipient validation — strict=False because the gateway may\n # not be reachable from caller's perspective at the moment;\n # we still want regex + alias resolution.\n canonical_to = validate_node_name(to, strict=False)\n\n # Best-effort credential leak guard. Operator can disable for\n # legitimate prose mentioning credential shapes.\n if not skip_secret_check:\n _check_no_secret(content)\n\n body: dict[str, Any] = {\n \"from_node\": self.node,\n \"to_node\": canonical_to,\n \"kind\": kind,\n \"content\": content,\n }\n if related_task_id is not None:\n body[\"related_task_id\"] = related_task_id\n if thread_id is not None:\n body[\"thread_id\"] = thread_id\n\n payload = await self._request(\"POST\", \"/messages\", json=body)\n return MeshMessage.model_validate(payload)\n\n async def mark_read(self, msg_id: int) -> None:\n \"\"\"``POST /messages/{msg_id}/read`` — flip ``read_at`` on a DM.\n\n Per CLAUDE.md droplet-side mesh DM drain rule, the cron-side\n watcher does NOT mark read; only the commander or the\n peer-in-session does. Adapter callers consuming inboxes\n should mark-read explicitly when they've actually processed\n the DM, not on fetch.\n \"\"\"\n await self._request(\"POST\", f\"/messages/{msg_id}/read\")\n"}, "27b320b1740708c6f5cf9781912f084a4c5ad51b": {"t": 1786068622.551706, "n": 19090, "h": "a43ffdc568899c6b", "c": "\"\"\"``MeshClient`` — async wrapper around the mesh-gateway HTTP API per\nPLAN.md §5.\n\nReplaces the hand-rolled curl-based code in:\n\n* an inbox-drain script\n* assorted ad-hoc curl invocations in session ritual\n\nwith a single typed surface so cross-Claude DM coordination doesn't\nrequire re-discovering the gateway shape from scratch every time.\n\nPublic surface:\n\n client = MeshClient(node=\"my-cell\", token=os.environ[\"MESH_GATEWAY_TOKEN\"])\n peers = await client.list_peers()\n msgs = await client.fetch(unread_only=True)\n sent = await client.send(to=\"other-cell\", kind=\"fyi\", content=\"...\")\n await client.mark_read(msg_id=123)\n await client.register(url=\"http://localhost:8787\", capabilities={...})\n\nTwo structural invariants enforced:\n\n1. **Recipient name validation.** Every ``send()`` calls\n ``swarph_shared.validate_node_name`` on the recipient — closes the\n Vector A (peer-onboarding chatter) + Vector B (human-prompt\n shorthand) framing-contagion classes documented in\n ``project_peer_name_canonical.md``.\n\n2. **Mesh-secrets out-of-band guard.** Every ``send()`` runs a\n best-effort regex sniff on content for obvious credentials\n (``pypi-...``, ``sk-...``, ``gh[ops]_...``, ``eyJ...`` JWTs).\n Hits raise :class:`MeshSecretLeakError` BEFORE the POST. CLAUDE.md\n \"Mesh secrets out-of-band only — NEVER ride /messages content\n fields\" is the non-negotiable rule. Best-effort because regex\n misses are common — operators must still treat content fields as\n public. The guard catches the obvious cases.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport re\nfrom typing import Any, Literal, Optional\n\n\n# v0.5.1 (drop DM #722 + #728): client-side enum for mesh-gateway-accepted\n# DM kinds. Gateway validates server-side too; this is fail-fast at the\n# type-check + runtime layer so callers don't round-trip a known-bad value\n# (e.g., kind=\"review_request\" → 400). Same shape as the validate_caller\n# pattern from swarph_shared. Add new kinds here when the gateway ships\n# them (current accepted set per server.py mesh-gateway):\nDM_KIND = Literal[\"fyi\", \"question\", \"answer\", \"status\", \"unblock\"]\n\nimport httpx\nfrom swarph_shared import validate_node_name\n\nfrom swarph_mesh.exceptions import SwarphMeshError\nfrom swarph_mesh.mesh_types import MeshMessage, MeshPeer\n\n\nDEFAULT_GATEWAY_URL = \"http://localhost:8788\"\nGATEWAY_TOKEN_ENV = \"MESH_GATEWAY_TOKEN\"\nDEFAULT_TIMEOUT_SECONDS = 10.0\n\n\n# ---------------------------------------------------------------------------\n# Exceptions\n# ---------------------------------------------------------------------------\n\n\nclass MeshGatewayError(SwarphMeshError):\n \"\"\"Gateway returned non-2xx or otherwise non-conformant response.\"\"\"\n\n\nclass MeshAuthError(MeshGatewayError):\n \"\"\"Authentication failed — token missing or rejected (401/403).\"\"\"\n\n\nclass MeshSecretLeakError(SwarphMeshError):\n \"\"\"Best-effort guard caught an apparent credential in DM content.\n\n Refuses to send. Operators must rotate the credential and resend\n without it.\n \"\"\"\n\n\n# ---------------------------------------------------------------------------\n# Best-effort credential leak detector\n# ---------------------------------------------------------------------------\n\n# Patterns chosen to match observable shapes of credentials seen in\n# session JSONLs in the field. Updates here are cheap; misses are\n# expected (defense-in-depth, not strict validation).\n_CRED_PATTERNS: list[tuple[str, re.Pattern[str]]] = [\n (\"pypi token\", re.compile(r\"\\bpypi-[A-Za-z0-9_-]{20,}\")),\n (\"anthropic api key\", re.compile(r\"\\bsk-ant-[A-Za-z0-9_-]{20,}\")),\n # OpenAI ADMIN keys (highest privilege class — can create/delete keys,\n # manage org). Listed FIRST so it matches before the generic openai\n # pattern, giving the operator a properly scary error message.\n # Endpoint that mints these: POST /v1/organization/admin_api_keys.\n (\"openai ADMIN key (org-level privilege)\", re.compile(r\"\\bsk-admin-[A-Za-z0-9_-]{20,}\")),\n # OpenAI key shapes: legacy ``sk-<48hex>`` + project keys\n # ``sk-proj-<long>`` (newer, longer alphabet incl. ``_-``). Earlier\n # regex ``sk-[A-Za-z0-9]{20,}`` missed sk-proj-... because of the\n # underscore + hyphen in the body. Issue #4 tightening.\n # NOTE: ``sk-admin-...`` is matched by the named pattern above so\n # this catch-all doesn't downgrade an admin key's error severity.\n (\"openai api key\", re.compile(r\"\\bsk-(?!admin-)(?:proj-)?[A-Za-z0-9_-]{20,}\")),\n # xAI / Grok key — xai-<long>. Same shape as OpenAI's hyphen-prefix\n # pattern. Added in v0.5.1 alongside the Grok adapter shipped in\n # v0.5.0; would have matched the key the user pasted in chat earlier\n # this session if mesh send had been the destination.\n (\"xai api key\", re.compile(r\"\\bxai-[A-Za-z0-9_-]{40,}\")),\n # DeepSeek api key — sk-<32hex> hex-only post the sk- prefix.\n # Already covered loosely by the openai pattern but listing\n # explicitly so the error message names the right provider.\n (\"deepseek api key\", re.compile(r\"\\bsk-[a-f0-9]{32}\\b\")),\n # Google Gemini / Cloud API keys — ``AIza<35-alnum>`` format.\n # Common shape across Google's API tier; would catch GEMINI_API_KEY\n # leaks. Issue #4 tightening (was missing).\n (\"google api key\", re.compile(r\"\\bAIza[0-9A-Za-z_-]{35}\")),\n (\"github token\", re.compile(r\"\\bgh[ops]_[A-Za-z0-9]{30,}\")),\n (\"aws access key\", re.compile(r\"\\bAKIA[0-9A-Z]{16}\\b\")),\n (\"aws secret key\", re.compile(r\"\\b[A-Za-z0-9/+=]{40}\\b(?=.*aws)\")), # heuristic\n # JWT — three base64url segments separated by dots (stricter than the\n # generic shape so we don't false-fire on every blob with two dots).\n (\"jwt\", re.compile(r\"\\beyJ[A-Za-z0-9_-]{15,}\\.eyJ[A-Za-z0-9_-]{15,}\\.[A-Za-z0-9_-]{15,}\\b\")),\n # Mesh gateway tokens — long random base64url-shaped values stored in\n # MESH_GATEWAY_TOKEN env. Heuristic shape: 40+ chars of url-safe\n # base64 alphabet. False-positive rate higher than other patterns —\n # operator escape via skip_secret_check=True is the right hatch.\n (\"possible mesh-gateway token\", re.compile(r\"\\b[A-Za-z0-9_-]{48,}\\b(?=.*MESH_GATEWAY_TOKEN)\")),\n]\n\n\ndef _check_no_secret(content: str) -> None:\n \"\"\"Raise :class:`MeshSecretLeakError` if content looks like it\n carries a credential. Best-effort; expected to miss novel shapes.\n \"\"\"\n # Exact-match the LIVE gateway token value. The regex pattern below uses a\n # `(?=.*MESH_GATEWAY_TOKEN)` lookahead, so it only fires when the literal\n # string 'MESH_GATEWAY_TOKEN' co-occurs — a bare token PASTE (the common\n # leak) sailed through. Comparing against the actual env value is exact\n # (zero false positives) and catches that case. (adversarial-sweep MED.)\n env_tok = os.environ.get(GATEWAY_TOKEN_ENV, \"\").strip()\n if env_tok and len(env_tok) >= 16 and env_tok in content:\n raise MeshSecretLeakError(\n f\"refusing to send: content contains the live {GATEWAY_TOKEN_ENV} \"\n \"value. Mesh secrets out-of-band only — NEVER ride /messages content \"\n \"fields. (CLAUDE.md mesh-secrets rule.)\"\n )\n for label, rx in _CRED_PATTERNS:\n if rx.search(content):\n raise MeshSecretLeakError(\n f\"refusing to send: content matches {label} pattern. \"\n \"Mesh secrets out-of-band only — NEVER ride /messages \"\n \"content fields. Use ssh+tee, commander manual paste, \"\n \"or pre-redacted Hub records. (CLAUDE.md mesh-secrets rule.)\"\n )\n\n\n# ---------------------------------------------------------------------------\n# MeshClient\n# ---------------------------------------------------------------------------\n\n\nclass MeshClient:\n \"\"\"Async client for the mesh-gateway HTTP API.\n\n Construct once per peer (or per cycle), reuse for the lifetime of\n the work. Backed by ``httpx.AsyncClient`` so connection-pooling\n happens for free across many ``send()`` calls.\n\n Use as an async context manager when you want explicit close:\n\n async with MeshClient(node=\"my-cell\") as client:\n await client.send(to=\"other-cell\", kind=\"fyi\", content=\"...\")\n\n Or instantiate normally + call ``aclose()`` when done.\n \"\"\"\n\n def __init__(\n self,\n node: str,\n *,\n token: Optional[str] = None,\n gateway_url: Optional[str] = None,\n timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,\n validate_self_name: bool = True,\n ):\n \"\"\"``token`` falls back to ``MESH_GATEWAY_TOKEN`` env. ``gateway_url``\n falls back to ``MESH_GATEWAY_URL`` env, then ``http://localhost:8788``.\n\n Set ``validate_self_name=False`` for test fixtures that need to\n instantiate a client with a mock peer name. Production callers\n should leave it on (caller's own ``node`` is the most common\n contagion target — see project_peer_name_canonical.md).\n \"\"\"\n # Self-name validation — best-effort. If the gateway is offline\n # we still want construction to succeed; the validation is\n # registry-dependent so use strict=False to skip the registry\n # check when offline.\n if validate_self_name:\n try:\n node = validate_node_name(node, strict=False)\n except Exception: # pragma: no cover — defensive\n pass\n\n self.node = node\n self._token = token if token is not None else os.environ.get(GATEWAY_TOKEN_ENV)\n self._gateway_url = (\n gateway_url\n or os.environ.get(\"MESH_GATEWAY_URL\")\n or DEFAULT_GATEWAY_URL\n )\n self._timeout = timeout_seconds\n self._client: Optional[httpx.AsyncClient] = None\n\n # ------------------------------------------------------------------\n # Lifecycle\n # ------------------------------------------------------------------\n\n def _ensure_client(self) -> httpx.AsyncClient:\n if self._client is None or self._client.is_closed:\n headers: dict[str, str] = {}\n if self._token:\n headers[\"Authorization\"] = f\"Bearer {self._token}\"\n self._client = httpx.AsyncClient(\n base_url=self._gateway_url,\n headers=headers,\n timeout=self._timeout,\n )\n return self._client\n\n async def aclose(self) -> None:\n if self._client is not None and not self._client.is_closed:\n await self._client.aclose()\n self._client = None\n\n async def __aenter__(self) -> \"MeshClient\":\n self._ensure_client()\n return self\n\n async def __aexit__(self, exc_type, exc, tb) -> None:\n await self.aclose()\n\n # ------------------------------------------------------------------\n # Internal HTTP wrapper\n # ------------------------------------------------------------------\n\n async def _request(\n self,\n method: str,\n path: str,\n *,\n params: Optional[dict[str, Any]] = None,\n json: Optional[dict[str, Any]] = None,\n ) -> Any:\n client = self._ensure_client()\n try:\n resp = await client.request(method, path, params=params, json=json)\n except httpx.RequestError as exc:\n raise MeshGatewayError(\n f\"mesh-gateway {method} {path} request failed: {exc}\"\n ) from exc\n\n if resp.status_code == 401 or resp.status_code == 403:\n raise MeshAuthError(\n f\"mesh-gateway {method} {path} returned {resp.status_code}: \"\n f\"check MESH_GATEWAY_TOKEN env\"\n )\n if resp.status_code >= 400:\n body = resp.text[:500]\n raise MeshGatewayError(\n f\"mesh-gateway {method} {path} returned {resp.status_code}: {body}\"\n )\n\n # Gateway returns JSON for everything except /health\n if not resp.content:\n return None\n try:\n return resp.json()\n except ValueError as exc:\n raise MeshGatewayError(\n f\"mesh-gateway {method} {path} returned non-JSON: {resp.text[:200]}\"\n ) from exc\n\n # ------------------------------------------------------------------\n # Peer endpoints\n # ------------------------------------------------------------------\n\n async def list_peers(self) -> list[MeshPeer]:\n \"\"\"``GET /peers`` — return all registered peers.\"\"\"\n payload = await self._request(\"GET\", \"/peers\")\n rows = payload.get(\"peers\", payload) if isinstance(payload, dict) else payload\n return [MeshPeer.model_validate(r) for r in rows]\n\n async def register(\n self,\n *,\n url: Optional[str] = None,\n capabilities: Optional[dict[str, Any]] = None,\n ) -> MeshPeer:\n \"\"\"``POST /peers/register`` — register this peer with the gateway.\"\"\"\n body: dict[str, Any] = {\"name\": self.node}\n if url is not None:\n body[\"url\"] = url\n if capabilities is not None:\n body[\"capabilities\"] = capabilities\n payload = await self._request(\"POST\", \"/peers/register\", json=body)\n return MeshPeer.model_validate(payload)\n\n # ------------------------------------------------------------------\n # Message endpoints\n # ------------------------------------------------------------------\n\n async def fetch(\n self,\n *,\n to_node: Optional[str] = None,\n unread_only: bool = False,\n limit: Optional[int] = None,\n ) -> list[MeshMessage]:\n \"\"\"``GET /messages`` — fetch own inbox.\n\n Defaults ``to_node`` to ``self.node``. Pass an explicit value\n only if you have a legitimate reason to peek at another peer's\n inbox (gateway access-controls this; most callers will get\n their own inbox only).\n\n v0.5.1 latent bug fix (discovered in Phase 5.6 daemon build):\n the mesh-gateway query parameter is ``?to=`` — NOT ``?to_node=``.\n The latter is silently ignored, returning ALL recent messages\n regardless of recipient. Pre-fix every ``MeshClient.fetch``\n caller saw outbound bleed-through unless they Python-side\n filtered. The kwarg keeps its descriptive name for the\n caller-facing API; only the wire-shape changes.\n \"\"\"\n # Defense-in-depth: filter from_node!=self_node client-side too,\n # so any future gateway quirk that re-introduces outbound\n # bleed-through doesn't silently break consumers (same shape\n # as the daemon's filter).\n target = to_node or self.node\n params: dict[str, Any] = {\"to\": target}\n if unread_only:\n params[\"unread_only\"] = \"true\"\n if limit is not None:\n params[\"limit\"] = limit\n payload = await self._request(\"GET\", \"/messages\", params=params)\n rows = payload.get(\"messages\", payload) if isinstance(payload, dict) else payload\n # Client-side defensive filter — only if fetching own inbox\n # (i.e., target == self.node). When peeking at another peer's\n # inbox via explicit to_node= override, return whatever gateway\n # returns without filtering (caller asked for raw view).\n if target == self.node:\n rows = [r for r in rows if r.get(\"from_node\") != self.node]\n return [MeshMessage.model_validate(r) for r in rows]\n\n async def send(\n self,\n *,\n to: str,\n kind: DM_KIND,\n content: str,\n related_task_id: Optional[str] = None,\n thread_id: Optional[str] = None,\n skip_secret_check: bool = False,\n ) -> MeshMessage:\n \"\"\"``POST /messages`` — send a DM.\n\n Validates ``to`` via ``swarph_shared.validate_node_name`` (closes\n Vector A + B contagion classes); raises ``ValueError`` /\n ``NotInRegistry`` on invalid recipient names BEFORE the POST.\n\n ``kind`` is a ``Literal`` enum (see ``DM_KIND`` above) — type\n checkers reject unknown values at lint time, and the runtime\n guard below catches anyone passing ``kind`` as ``str``-typed\n from a callsite that bypassed type-checking.\n\n Runs the best-effort credential leak detector on ``content``.\n Set ``skip_secret_check=True`` only when content has been\n inspected by the caller and the leak detector is producing a\n false positive (e.g. discussing a credential format in\n prose). False positives should be rare; treat them as a\n signal that the content is on a fence and rotate aggressively\n if you're not 100% sure.\n \"\"\"\n # Runtime kind enum check — defense-in-depth against str-typed\n # callsites that slipped past type-checking. Same shape as\n # validate_caller fail-fast discipline.\n if kind not in {\"fyi\", \"question\", \"answer\", \"status\", \"unblock\"}:\n raise ValueError(\n f\"MeshClient.send: kind={kind!r} is not a valid mesh-gateway \"\n f\"DM kind. Accepted: fyi / question / answer / status / unblock. \"\n f\"(Adding new kinds requires gateway-side support; check \"\n f\"server.py POST /messages handler.)\"\n )\n\n # Recipient validation — FAIL-CLOSED (strict=True). A send is about to\n # hit the same gateway via POST /messages, so the registry's /peers is\n # reachable in the normal case (and a warm TTL cache covers transient\n # blips). strict=False used to fail OPEN: a /peers hiccup skipped the\n # registry check and let a regex-valid-but-unregistered name through,\n # POSTing the DM into the void (adversarial-sweep auth-bypass HIGH).\n # Pass this client's own gateway_url + token so the registry check hits\n # the SAME gateway we POST to (not the MESH_GATEWAY_URL env default).\n canonical_to = validate_node_name(\n to, strict=True, gateway_url=self._gateway_url, token=self._token,\n )\n\n # Best-effort credential leak guard. Operator can disable for\n # legitimate prose mentioning credential shapes.\n if not skip_secret_check:\n _check_no_secret(content)\n\n body: dict[str, Any] = {\n \"from_node\": self.node,\n \"to_node\": canonical_to,\n \"kind\": kind,\n \"content\": content,\n }\n if related_task_id is not None:\n body[\"related_task_id\"] = related_task_id\n if thread_id is not None:\n body[\"thread_id\"] = thread_id\n\n payload = await self._request(\"POST\", \"/messages\", json=body)\n return MeshMessage.model_validate(payload)\n\n async def mark_read(self, msg_id: int) -> None:\n \"\"\"``POST /messages/{msg_id}/read`` — flip ``read_at`` on a DM.\n\n Per the mesh DM drain convention, the cron-side\n watcher does NOT mark read; only the commander or the\n peer-in-session does. Adapter callers consuming inboxes\n should mark-read explicitly when they've actually processed\n the DM, not on fetch.\n \"\"\"\n await self._request(\"POST\", f\"/messages/{msg_id}/read\")\n"}}}}