kojee-mcp 0.5.13 → 0.5.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,344 @@
1
+ """Pure logic for the ``kojee-tandem`` Hermes platform adapter.
2
+
3
+ This module has ZERO Hermes imports (stdlib only) so the mapping, signature
4
+ verification, dedupe, and outbound-payload contracts are unit-testable on any
5
+ box — including one where neither Hermes nor aiohttp is installed.
6
+ ``adapter.py`` (the Hermes-facing shim) imports from here.
7
+
8
+ Wire contracts pinned against:
9
+ - TandemEvent: kojee-mcp-server src/types/tandem.ts (0.5.3)
10
+ - Webhook signature: src/tandem/webhook-sink.ts computeWebhookSignature()
11
+ (hex SHA-256 HMAC of the RAW body bytes) + the emission knobs in
12
+ src/tandem/webhook-config.ts (default header X-Kojee-Signature with a bare
13
+ hex digest; "github" preset = X-Hub-Signature-256 with "sha256=" prefix).
14
+ - Session id derivation: src/gateway-client.ts deriveSessionId()
15
+ (sha256(token + "proxy") hex, first 16 chars).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import hmac
22
+ import json
23
+ from collections import OrderedDict
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import Any, Mapping, Optional
27
+
28
+ # ── Signature verification ──────────────────────────────────────────────────
29
+
30
+ # Default kojee-mcp emission (0.5.2 wire contract): bare hex digest.
31
+ KOJEE_SIGNATURE_HEADER = "X-Kojee-Signature"
32
+ # The "github" preset (KOJEE_WEBHOOK_SIGNATURE_FORMAT=github): sha256=<hex>.
33
+ GITHUB_SIGNATURE_HEADER = "X-Hub-Signature-256"
34
+ GITHUB_SIGNATURE_PREFIX = "sha256="
35
+ # Delivery id header the sink sets on every POST (event id, receiver dedupe key).
36
+ DELIVERY_ID_HEADER = "X-Kojee-Delivery"
37
+
38
+
39
+ def compute_signature(secret: str, raw_body: bytes) -> str:
40
+ """Hex SHA-256 HMAC of the raw request-body bytes (mirror of
41
+ webhook-sink.ts computeWebhookSignature — same bytes, same key)."""
42
+ return hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
43
+
44
+
45
+ def _header_lookup(headers: Mapping[str, str], name: str) -> Optional[str]:
46
+ """Case-insensitive header fetch from a plain mapping (aiohttp's CIMultiDict
47
+ is already case-insensitive, but tests use plain dicts)."""
48
+ getter = getattr(headers, "get", None)
49
+ if getter is not None:
50
+ direct = getter(name)
51
+ if direct is not None:
52
+ return direct
53
+ lowered = name.lower()
54
+ for key in headers:
55
+ if key.lower() == lowered:
56
+ return headers[key]
57
+ return None
58
+
59
+
60
+ def verify_signature(headers: Mapping[str, str], raw_body: bytes, secret: str) -> bool:
61
+ """True when the request carries a valid HMAC for ``raw_body``.
62
+
63
+ Accepts EITHER emission the kojee-mcp daemon can be configured for —
64
+ the default ``X-Kojee-Signature: <hex>`` or the github preset
65
+ ``X-Hub-Signature-256: sha256=<hex>`` — so a header/prefix mismatch
66
+ between the daemon env and the adapter config is not a bug class here.
67
+ Fails closed: no secret or no recognized header ⇒ False.
68
+ """
69
+ if not secret:
70
+ return False
71
+ expected = compute_signature(secret, raw_body)
72
+
73
+ bare = _header_lookup(headers, KOJEE_SIGNATURE_HEADER)
74
+ if bare is not None and hmac.compare_digest(bare.strip(), expected):
75
+ return True
76
+
77
+ github = _header_lookup(headers, GITHUB_SIGNATURE_HEADER)
78
+ if github is not None:
79
+ value = github.strip()
80
+ if value.startswith(GITHUB_SIGNATURE_PREFIX):
81
+ value = value[len(GITHUB_SIGNATURE_PREFIX):]
82
+ if hmac.compare_digest(value, expected):
83
+ return True
84
+
85
+ return False
86
+
87
+
88
+ # ── Inbound: TandemEvent → normalized message ───────────────────────────────
89
+
90
+ @dataclass
91
+ class NormalizedTandemMessage:
92
+ """A TandemEvent reduced to what a Hermes MessageEvent needs."""
93
+
94
+ chat_id: str # tandem_id — the per-tandem chat id
95
+ message_id: str # cursor (stringified) — stable per-tandem ordering
96
+ event_id: str # ULID — globally unique, the dedupe key
97
+ text: str # content.body
98
+ sender_id: str # from.principal (falls back to member_id)
99
+ sender_name: str # from.displayname (falls back to principal)
100
+ sender_session_id: Optional[str] # from.session_id (self-echo filter)
101
+ reply_to: Optional[str]
102
+ kind: str
103
+ severity: Optional[str]
104
+
105
+
106
+ def normalize_event(event: Mapping[str, Any]) -> Optional[NormalizedTandemMessage]:
107
+ """Map a TandemEvent JSON body to a normalized message, or None when the
108
+ event is not an injectable chat message (state_change, empty body,
109
+ missing tandem_id). The adapter ACKs-and-ignores None — a non-message
110
+ event must never 4xx (the sink would classify it permanent and drop it,
111
+ or retry-burn on 5xx)."""
112
+ if not isinstance(event, Mapping):
113
+ return None
114
+ if event.get("type") != "message":
115
+ return None
116
+ tandem_id = event.get("tandem_id")
117
+ if not tandem_id or not isinstance(tandem_id, str):
118
+ return None
119
+ content = event.get("content") or {}
120
+ body = content.get("body") if isinstance(content, Mapping) else None
121
+ if not body or not isinstance(body, str):
122
+ return None
123
+
124
+ sender = event.get("from") or {}
125
+ if not isinstance(sender, Mapping):
126
+ sender = {}
127
+ principal = sender.get("principal") or sender.get("member_id") or "unknown"
128
+ displayname = sender.get("displayname") or principal
129
+
130
+ cursor = event.get("cursor")
131
+ event_id = str(event.get("id") or "")
132
+ # cursor is the per-tandem message id (the directive's mapping); fall back
133
+ # to the ULID when a wire frame omits it so message_id is never empty.
134
+ message_id = str(cursor) if cursor is not None else event_id
135
+
136
+ reply_to = event.get("reply_to")
137
+ severity = event.get("severity")
138
+
139
+ return NormalizedTandemMessage(
140
+ chat_id=tandem_id,
141
+ message_id=message_id,
142
+ event_id=event_id or message_id,
143
+ text=body,
144
+ sender_id=str(principal),
145
+ sender_name=str(displayname),
146
+ sender_session_id=(
147
+ str(sender["session_id"]) if sender.get("session_id") else None
148
+ ),
149
+ reply_to=str(reply_to) if reply_to else None,
150
+ kind=str(event.get("kind") or "message"),
151
+ severity=str(severity) if severity else None,
152
+ )
153
+
154
+
155
+ # ── Dedupe (the receiver side of the sink's at-least-once contract) ─────────
156
+
157
+ class EventDeduper:
158
+ """Bounded insertion-ordered set of seen event ids.
159
+
160
+ The webhook sink is at-least-once: after a daemon restart the
161
+ resubscribe-replay REDELIVERS recent events, and retries can double-send.
162
+ The receiver contract (recipe.ts) says: dedupe by event id / cursor.
163
+ """
164
+
165
+ def __init__(self, maxlen: int = 4096):
166
+ self._maxlen = max(1, maxlen)
167
+ self._seen: "OrderedDict[str, None]" = OrderedDict()
168
+
169
+ def seen_before(self, event_id: str) -> bool:
170
+ """Mark ``event_id`` seen; True when it had already been seen."""
171
+ if event_id in self._seen:
172
+ self._seen.move_to_end(event_id)
173
+ return True
174
+ self._seen[event_id] = None
175
+ while len(self._seen) > self._maxlen:
176
+ self._seen.popitem(last=False)
177
+ return False
178
+
179
+
180
+ # ── Filters ─────────────────────────────────────────────────────────────────
181
+
182
+ def parse_allowlist(raw: Optional[str]) -> frozenset:
183
+ """Comma-separated tandem ids → set. Empty/None ⇒ empty set = allow all."""
184
+ if not raw:
185
+ return frozenset()
186
+ return frozenset(part.strip() for part in raw.split(",") if part.strip())
187
+
188
+
189
+ def tandem_allowed(tandem_id: str, allowlist: frozenset) -> bool:
190
+ """Empty allowlist ⇒ every tandem is allowed (Hermes's per-user
191
+ authorization still applies downstream via KOJEE_TANDEM_ALLOWED_USERS)."""
192
+ return not allowlist or tandem_id in allowlist
193
+
194
+
195
+ def is_self_event(
196
+ msg: NormalizedTandemMessage,
197
+ self_session_id: Optional[str],
198
+ self_principal: Optional[str],
199
+ ) -> bool:
200
+ """Self-echo filter (reply-loop prevention, ADDING_A_PLATFORM checklist).
201
+
202
+ The daemon and the send helper share ONE deterministic session id
203
+ (sha256(token+"proxy")[:16] — gateway-client.ts deriveSessionId), so a
204
+ fan-out echo of our own tandem_send carries from.session_id == ours.
205
+ The principal check is the operator-config fallback for wire frames
206
+ that omit session_id.
207
+ """
208
+ if self_session_id and msg.sender_session_id == self_session_id:
209
+ return True
210
+ if self_principal and msg.sender_id == self_principal:
211
+ return True
212
+ return False
213
+
214
+
215
+ def derive_session_id(token: str) -> str:
216
+ """Mirror of GatewayClient.deriveSessionId (src/gateway-client.ts):
217
+ session_id = sha256(token + "proxy") hex, first 16 chars."""
218
+ return hashlib.sha256((token + "proxy").encode("utf-8")).hexdigest()[:16]
219
+
220
+
221
+ def load_self_session_id(kojee_dir: Path) -> Optional[str]:
222
+ """Best-effort: read ~/.kojee/config.json (paired mode) and derive the
223
+ daemon/helper session id for the self-echo filter. None when unpaired or
224
+ unreadable — the adapter then relies on KOJEE_TANDEM_SELF_PRINCIPAL."""
225
+ try:
226
+ raw = (kojee_dir / "config.json").read_text(encoding="utf-8")
227
+ token = json.loads(raw).get("token")
228
+ if token and isinstance(token, str):
229
+ return derive_session_id(token)
230
+ except (OSError, ValueError):
231
+ pass
232
+ return None
233
+
234
+
235
+ # ── Outbound: send → `kojee-mcp send` CLI contract ──────────────────────────
236
+ #
237
+ # T10: outbound moved from kojee_send.mjs (tsx + dynamic source imports →
238
+ # REQUIRED a kojee-mcp source checkout) to the shipped `kojee-mcp send`
239
+ # sub-command (0.5.4+). This kills the "needs a checkout" requirement — the
240
+ # adapter now needs only a paired ~/.kojee + the kojee-mcp binary on PATH.
241
+ #
242
+ # CLI contract (src/cli.ts + src/tandem/send.ts):
243
+ # kojee-mcp send <tandem_id> --body <text> [--reply-to <id>] [--kind <kind>]
244
+ # stdout: one JSON envelope.
245
+ # success: {ok:true, tandem_id, message_id, cursor, text} (exit 0)
246
+ # failure: {ok:false, error:<typed code>, message:<human text>} (exit 1)
247
+
248
+ KOJEE_MCP_BIN_ENV = "KOJEE_MCP_BIN"
249
+ # PATH fallback when KOJEE_MCP_BIN is unset (fold B).
250
+ DEFAULT_KOJEE_MCP_BIN = "kojee-mcp"
251
+
252
+
253
+ def resolve_kojee_mcp_bin(env: Mapping[str, str]) -> str:
254
+ """The kojee-mcp binary the outbound send shells.
255
+
256
+ Fold B: the plugin runs inside the gateway process, where the login-shell
257
+ PATH may not apply — so the installer writes an ABSOLUTE path into
258
+ ``KOJEE_MCP_BIN``. When that env is unset/empty we fall back to the bare
259
+ ``kojee-mcp`` name and rely on PATH resolution.
260
+ """
261
+ # TODO(hermes-installer): wizard writes KOJEE_MCP_BIN at install
262
+ # (src/wizard/installers/hermes.ts) — until then this fallback carries it.
263
+ return env.get(KOJEE_MCP_BIN_ENV) or DEFAULT_KOJEE_MCP_BIN
264
+
265
+
266
+ def build_send_command(
267
+ bin_path: str,
268
+ tandem_id: str,
269
+ body: str,
270
+ reply_to: Optional[str] = None,
271
+ kind: Optional[str] = None,
272
+ ) -> list:
273
+ """Argv for `kojee-mcp send <tandem_id> --body <text> [--reply-to] [--kind]`.
274
+
275
+ ``bin_path`` is the caller-resolved kojee-mcp binary (see
276
+ resolve_kojee_mcp_bin). body/reply_to/kind ride argv directly (asyncio's
277
+ exec passes argv to the kernel — no shell, so no quoting hazard).
278
+ reply_to/kind are OMITTED when absent, never passed empty.
279
+ """
280
+ argv = [bin_path, "send", tandem_id, "--body", body]
281
+ if reply_to:
282
+ argv += ["--reply-to", reply_to]
283
+ if kind:
284
+ argv += ["--kind", kind]
285
+ return argv
286
+
287
+
288
+ def parse_send_envelope(stdout: str) -> dict:
289
+ """Parse the `kojee-mcp send` JSON envelope from stdout.
290
+
291
+ Returns a normalized dict::
292
+
293
+ {"ok": bool,
294
+ "message_id": Optional[str],
295
+ "cursor": Optional[int],
296
+ "error": Optional[str], # human-readable failure text
297
+ "error_code": Optional[str]} # the typed SendErrorCode
298
+
299
+ Tolerates noise lines before the envelope (node warnings) by scanning for
300
+ the LAST parseable JSON object carrying ``ok``. No envelope ⇒ ok=False with
301
+ the raw tail as the error — a CLI crash must surface, not masquerade as a
302
+ delivered send.
303
+ """
304
+ envelope = None
305
+ for line in stdout.splitlines():
306
+ line = line.strip()
307
+ if not line.startswith("{"):
308
+ continue
309
+ try:
310
+ candidate = json.loads(line)
311
+ except ValueError:
312
+ continue
313
+ if isinstance(candidate, dict) and "ok" in candidate:
314
+ envelope = candidate
315
+ if envelope is None:
316
+ tail = stdout.strip()[-300:] if stdout.strip() else "(no output)"
317
+ return {
318
+ "ok": False,
319
+ "message_id": None,
320
+ "cursor": None,
321
+ "error": f"kojee-mcp send produced no envelope: {tail}",
322
+ "error_code": None,
323
+ }
324
+ if envelope.get("ok"):
325
+ raw_cursor = envelope.get("cursor")
326
+ return {
327
+ "ok": True,
328
+ "message_id": (
329
+ str(envelope["message_id"]) if envelope.get("message_id") else None
330
+ ),
331
+ "cursor": raw_cursor if isinstance(raw_cursor, int) else None,
332
+ "error": None,
333
+ "error_code": None,
334
+ }
335
+ # Failure envelope: {ok:false, error:<typed code>, message:<human text>}.
336
+ code = str(envelope["error"]) if envelope.get("error") else None
337
+ message = str(envelope["message"]) if envelope.get("message") else None
338
+ return {
339
+ "ok": False,
340
+ "message_id": None,
341
+ "cursor": None,
342
+ "error": message or code or "kojee-mcp send failed",
343
+ "error_code": code,
344
+ }
@@ -0,0 +1,58 @@
1
+ name: kojee-tandem
2
+ label: Kojee Tandem
3
+ kind: platform
4
+ version: 0.1.0
5
+ description: >
6
+ Kojee Tandem gateway adapter for Hermes Agent (v1, sidecar architecture).
7
+ Makes Tandem — Kojee's shared human+agent conversations — a first-class
8
+ Hermes channel: the kojee-mcp daemon (DPoP auth, SSE event stream) delivers
9
+ Tandem messages to this adapter's loopback webhook listener, and replies go
10
+ out through the shipped `kojee-mcp send` CLI via tandem_send. Needs a paired
11
+ ~/.kojee and the kojee-mcp binary on the box (no source checkout required).
12
+ author: Kojee
13
+ requires_env:
14
+ - name: KOJEE_WEBHOOK_SECRET
15
+ description: "HMAC secret shared with the kojee-mcp daemon's webhook sink (the adapter rejects unsigned events)"
16
+ prompt: "Webhook HMAC secret"
17
+ password: true
18
+ optional_env:
19
+ - name: KOJEE_MCP_BIN
20
+ description: "Absolute path to the kojee-mcp binary the outbound `send` shells (default: `kojee-mcp` on PATH). Set when the binary is not on the gateway process PATH."
21
+ prompt: "kojee-mcp binary path (optional)"
22
+ password: false
23
+ - name: KOJEE_TANDEM_LISTEN_HOST
24
+ description: "Webhook listener bind host (default 127.0.0.1 — keep it loopback)"
25
+ prompt: "Listen host"
26
+ password: false
27
+ - name: KOJEE_TANDEM_LISTEN_PORT
28
+ description: "Webhook listener port (default 8645); the daemon's KOJEE_WEBHOOK_URL must point here"
29
+ prompt: "Listen port"
30
+ password: false
31
+ - name: KOJEE_TANDEM_LISTEN_PATH
32
+ description: "Webhook listener path (default /kojee-tandem)"
33
+ prompt: "Listen path"
34
+ password: false
35
+ - name: KOJEE_TANDEM_ALLOWLIST
36
+ description: "Comma-separated tandem ids allowed to wake the agent (empty = all tandems)"
37
+ prompt: "Tandem allowlist (comma-separated, or empty)"
38
+ password: false
39
+ - name: KOJEE_TANDEM_ALLOWED_USERS
40
+ description: "Comma-separated Tandem principals allowed to talk to the bot (e.g. user:daria@cohen.io)"
41
+ prompt: "Allowed principals (comma-separated)"
42
+ password: false
43
+ - name: KOJEE_TANDEM_ALLOW_ALL_USERS
44
+ description: "Allow every tandem member to talk to the bot (1/true/yes)"
45
+ prompt: "Allow all users? (true/false)"
46
+ password: false
47
+ - name: KOJEE_TANDEM_SELF_PRINCIPAL
48
+ description: "Extra self-echo filter for wire frames without sender session_id. WARNING: setting this to the shared account principal silences EVERYONE sharing it, including your owner (the principal is shared by the human owner and all sibling agents) — defaults are safe, leave unset"
49
+ prompt: "Self principal (optional)"
50
+ password: false
51
+ - name: KOJEE_TANDEM_HOME_CHANNEL
52
+ description: "Default tandem id for cron / notification delivery (deliver=kojee-tandem)"
53
+ prompt: "Home tandem id (optional)"
54
+ password: false
55
+ - name: KOJEE_DIR
56
+ description: "Kojee state dir holding config.json + keypair.json (default ~/.kojee)"
57
+ prompt: "Kojee dir (optional)"
58
+ password: false