kojee-mcp 0.5.2 → 0.5.4

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.
package/README.md CHANGED
@@ -220,6 +220,35 @@ in it is Hermes-specific — and it is **OFF by default**.
220
220
  | `KOJEE_WEBHOOK_SECRET` | HMAC-SHA256 key for the signature header. URL set but secret unset ⇒ sink **DISABLED with an error** (the proxy NEVER sends unsigned webhooks). |
221
221
  | `KOJEE_WEBHOOK_TIMEOUT_MS` | Per-attempt request timeout (default `5000`). |
222
222
  | `KOJEE_WEBHOOK_MAX_RETRIES` | Retries on a retryable failure — network / 5xx / 408 / 429 (default `4`). |
223
+ | `KOJEE_WEBHOOK_SIGNATURE_HEADER` | Header name carrying the signature (default `X-Kojee-Signature`). |
224
+ | `KOJEE_WEBHOOK_SIGNATURE_PREFIX` | Literal string prepended to the hex digest (default empty — bare hex). |
225
+ | `KOJEE_WEBHOOK_SIGNATURE_FORMAT` | Optional preset. `github` ⇒ header `X-Hub-Signature-256`, prefix `sha256=` (the GitHub-webhook convention). Explicit `_HEADER`/`_PREFIX` vars override the preset's corresponding value. Unknown values are **warned about once and ignored** — never fatal. |
226
+
227
+ **Signature emission is configurable (0.5.3)** — the HMAC *computation* never
228
+ changes (hex SHA-256 HMAC of the raw body bytes, keyed by
229
+ `KOJEE_WEBHOOK_SECRET`); only the header name and an optional digest prefix do.
230
+ Defaults are byte-identical to 0.5.2. For a GitHub-convention receiver (Hermes
231
+ and most off-the-shelf webhook verifiers):
232
+
233
+ ```bash
234
+ export KOJEE_WEBHOOK_SIGNATURE_FORMAT=github
235
+ # Each POST then carries:
236
+ # X-Hub-Signature-256: sha256=<hex HMAC-SHA256 of the raw body, keyed by KOJEE_WEBHOOK_SECRET>
237
+ # which verifies with any GitHub-style check, e.g.:
238
+ # expected = "sha256=" + HMAC_SHA256_hex(secret, raw_body)
239
+ # timing_safe_equal(header, expected)
240
+ ```
241
+
242
+ Or set the pieces individually (these override the preset):
243
+
244
+ ```bash
245
+ export KOJEE_WEBHOOK_SIGNATURE_HEADER="X-Hub-Signature-256"
246
+ export KOJEE_WEBHOOK_SIGNATURE_PREFIX="sha256="
247
+ ```
248
+
249
+ The wizard can persist this non-interactively, e.g.
250
+ `kojee-mcp init --runtime hermes --webhook-url https://… --webhook-signature-format github`
251
+ (also `--webhook-signature-header` / `--webhook-signature-prefix`).
223
252
 
224
253
  **Receiver contract** (the single source of truth is `buildWebhookReceiverNote()`
225
254
  in `src/tandem/recipe.ts`, and the exact body shape is the `WEBHOOK_BODY_SHAPE`
@@ -251,6 +280,94 @@ never delay or break the Monitor (event-log) or Channel wake paths. The status
251
280
  log redacts the secret and strips any basic-auth credentials embedded in
252
281
  `KOJEE_WEBHOOK_URL`.
253
282
 
283
+ ## Wake Continuity (0.5.4)
284
+
285
+ The event-stream subscription is a **connect-time snapshot of the caller's
286
+ memberships**. A daemon that rotates its session identity across restarts used
287
+ to come up with its tandem seat still bound to the OLD session — subscribed to
288
+ nothing, deaf to webhook wakes, while sends kept working (agent-scoped
289
+ fallback). Two mechanisms close this permanently:
290
+
291
+ **Ensure-join at startup.** After auth and *before* the event stream connects,
292
+ the daemon ensure-joins its live session (`tandem_join` is idempotent — an
293
+ existing seat returns `already_member`). One log line per tandem (`joined
294
+ fresh` vs `already seated`); failures warn and continue (a bad id never kills
295
+ the daemon).
296
+
297
+ | `KOJEE_TANDEMS` | Behavior |
298
+ |---|---|
299
+ | *(unset — the default)* | **Auto**: `tandem_list` (which is **principal-scoped** — it also lists rooms where only *sibling* agents of the principal sit), **filtered to rows where THIS agent holds an active seat** (`my_membership.is_member === true`; rows missing the flag are excluded — fail closed), then ensure-join each with the live session. Auto mode re-seats the agent where it already belongs; it never joins rooms the agent was not in. |
300
+ | `<id>,<id>,…` | Join exactly these tandem ObjectId hexes (24-hex; invalid entries warned + skipped). |
301
+ | `none` | Disable ensure-join entirely. |
302
+
303
+ **Stream reconnect after join.** Any successful `tandem_join` performed by the
304
+ daemon path (the startup ensure-join, or the MCP tool called through the proxy
305
+ by its agent) triggers a graceful event-stream reconnect (close + reconnect via
306
+ the existing backoff machinery, resuming from the per-room cursors), so the
307
+ subscription snapshot always includes just-acquired seats. Multiple joins in a
308
+ burst are debounced into one reconnect. No daemon restart needed, ever.
309
+
310
+ ## Local Send Control Surface (0.5.4)
311
+
312
+ One stable, supported way to **send** a Tandem message from *outside* the proxy
313
+ process — for native gateway plugins, scripts, and humans. Two halves, one
314
+ shared core (`src/tandem/send.ts`), one envelope contract.
315
+
316
+ ### CLI: `kojee-mcp send`
317
+
318
+ ```bash
319
+ kojee-mcp send <tandem_id> --body "hello" [--reply-to <message_id>] [--kind message|status]
320
+ ```
321
+
322
+ Uses the machine's **paired** credentials — the same `~/.kojee/config.json` +
323
+ `~/.kojee/keypair.json` the proxy reads, the same DPoP/GatewayClient path, the
324
+ same deterministic session seat. Prints **one JSON envelope** to stdout and
325
+ exits `0`/`1`:
326
+
327
+ ```json
328
+ {"ok":true,"tandem_id":"T-1","message_id":"m_123","cursor":42,"text":"..."}
329
+ {"ok":false,"error":"content_blocked","message":"content blocked by gateway — ..."}
330
+ ```
331
+
332
+ ### HTTP: `POST /send` on the running daemon's hook-server
333
+
334
+ Lets a plugin riding a **running** daemon send without spawning Node. Find the
335
+ port and the bearer via the session-discovery file
336
+ (`~/.kojee/sessions/cc-<key>.json` → `port`, `controlTokenPath`):
337
+
338
+ ```bash
339
+ curl -s -X POST "http://127.0.0.1:$PORT/send" \
340
+ -H "Authorization: Bearer $(cat ~/.kojee/control-token)" \
341
+ -H "Content-Type: application/json" \
342
+ -d '{"tandem_id":"T-1","body":"hello","reply_to":"m_0","kind":"message"}'
343
+ ```
344
+
345
+ Returns the **same JSON envelope** as the CLI. HTTP status mirrors the typed
346
+ error: `200` ok · `400 bad_request` · `401 unauthorized` ·
347
+ `403 content_blocked / member_cap / not_member / governance_denied` ·
348
+ `429 rate_limited` · `502` upstream (gateway auth/network/unknown) ·
349
+ `503 send_unavailable`.
350
+
351
+ **Auth.** Every endpoint that returns or writes this principal's data —
352
+ `POST /send`, `GET /poll`, `GET /status` — requires the same bearer: a fresh
353
+ random token issued at every daemon start, stored `0600` at
354
+ `~/.kojee/control-token` (rotates on restart — re-read the file, don't cache).
355
+ Presenting it proves the caller can read this user's files; other local users
356
+ and browser drive-by requests cannot. Only `GET /health` (a liveness ping
357
+ carrying no data) stays open. The in-tree consumers (the Claude Code Stop /
358
+ UserPromptSubmit hooks and `kojee-mcp doctor`) read the token from the path
359
+ advertised in the session-discovery file and send it automatically; the
360
+ Monitor wake path tails the event-log *file* and never touches `/poll`. If
361
+ the daemon could not issue a token at start (exotic FS), the reads degrade
362
+ open and `POST /send` answers `503` — loudly logged.
363
+
364
+ **Typed errors** (`error` field, both surfaces): `member_cap`,
365
+ `content_blocked` (the gateway WAF rejecting message *content* — commonly a
366
+ literal URL in the body; de-fang or split it; this is **not** an auth failure),
367
+ `gateway_auth`, `not_member`, `rate_limited`, `network`, `governance_denied`,
368
+ `approval_required`, `not_paired`, `not_enrolled`, `bad_request`,
369
+ `unauthorized`, `send_unavailable`, `send_failed` (raw text preserved).
370
+
254
371
  ## Development
255
372
 
256
373
  Run tests:
@@ -0,0 +1,35 @@
1
+ // src/auth/dpop.ts
2
+ import { SignJWT, base64url } from "jose";
3
+ import crypto from "crypto";
4
+ async function createDPoPProof(privateKey, kid, method, url, nonce, accessToken) {
5
+ const payload = {
6
+ htm: method,
7
+ htu: url,
8
+ jti: crypto.randomUUID()
9
+ };
10
+ if (nonce) {
11
+ payload.nonce = nonce;
12
+ }
13
+ if (accessToken) {
14
+ payload.ath = computeAth(accessToken);
15
+ }
16
+ const header = {
17
+ typ: "dpop+jwt",
18
+ alg: "ES256",
19
+ jwk: { kid }
20
+ };
21
+ return new SignJWT(payload).setProtectedHeader(header).setIssuedAt().sign(privateKey);
22
+ }
23
+ function computeAth(accessToken) {
24
+ const hash = crypto.createHash("sha256").update(accessToken).digest();
25
+ return base64url.encode(hash);
26
+ }
27
+
28
+ // src/tandem/session-id.ts
29
+ import { ulid } from "ulidx";
30
+ var MCP_SESSION_ID = ulid();
31
+
32
+ export {
33
+ createDPoPProof,
34
+ MCP_SESSION_ID
35
+ };
@@ -0,0 +1,213 @@
1
+ import {
2
+ MCP_SESSION_ID,
3
+ createDPoPProof
4
+ } from "./chunk-2MIISF2W.js";
5
+ import {
6
+ translateHttpError,
7
+ translateJsonRpcError,
8
+ translateNetworkError
9
+ } from "./chunk-LDZXU3DW.js";
10
+ import {
11
+ secureDir,
12
+ secureFile
13
+ } from "./chunk-BLEGIR35.js";
14
+
15
+ // src/auth/keystore.ts
16
+ import { importJWK, exportJWK, generateKeyPair } from "jose";
17
+ import fs from "fs";
18
+ import os from "os";
19
+ import path from "path";
20
+ var DEFAULT_PATH = path.join(os.homedir(), ".kojee", "keypair.json");
21
+ async function loadKeystore(keystorePath = DEFAULT_PATH, expectedBrokerUrl) {
22
+ if (!fs.existsSync(keystorePath)) {
23
+ return null;
24
+ }
25
+ const raw = fs.readFileSync(keystorePath, "utf-8");
26
+ const data = JSON.parse(raw);
27
+ if (expectedBrokerUrl && data.broker_url !== expectedBrokerUrl) {
28
+ return null;
29
+ }
30
+ const privateKey = await importJWK(data.private_key_jwk, "ES256");
31
+ return {
32
+ privateKey,
33
+ publicJwk: data.public_jwk,
34
+ kid: data.kid,
35
+ data
36
+ };
37
+ }
38
+ async function saveKeystore(privateKey, publicJwk, kid, brokerUrl, keystorePath = DEFAULT_PATH) {
39
+ const dir = path.dirname(keystorePath);
40
+ fs.mkdirSync(dir, { recursive: true, mode: 448 });
41
+ secureDir(dir);
42
+ const privateJwk = await exportJWK(privateKey);
43
+ const data = {
44
+ private_key_jwk: privateJwk,
45
+ kid,
46
+ broker_url: brokerUrl,
47
+ public_jwk: publicJwk,
48
+ enrolled_at: (/* @__PURE__ */ new Date()).toISOString()
49
+ };
50
+ fs.writeFileSync(keystorePath, JSON.stringify(data, null, 2), {
51
+ mode: 384
52
+ });
53
+ secureFile(keystorePath);
54
+ }
55
+ async function generateES256KeyPair() {
56
+ const { privateKey, publicKey } = await generateKeyPair("ES256");
57
+ const publicJwk = await exportJWK(publicKey);
58
+ publicJwk.kty = "EC";
59
+ publicJwk.crv = "P-256";
60
+ return { privateKey, publicJwk };
61
+ }
62
+
63
+ // src/gateway-client.ts
64
+ import crypto from "crypto";
65
+ var GatewayClient = class {
66
+ constructor(brokerUrl, token, privateKey, kid, sessionId) {
67
+ this.brokerUrl = brokerUrl;
68
+ this.token = token;
69
+ this.privateKey = privateKey;
70
+ this.kid = kid;
71
+ this.endpoint = `${brokerUrl}/mcp/messages/${sessionId}/`;
72
+ }
73
+ brokerUrl;
74
+ token;
75
+ privateKey;
76
+ kid;
77
+ currentNonce;
78
+ requestCounter = 0;
79
+ endpoint;
80
+ /**
81
+ * Expose the DPoP signing key so peer modules sharing auth state
82
+ * (e.g. tandem/event-stream.ts) can sign their own requests.
83
+ */
84
+ getPrivateKey() {
85
+ return this.privateKey;
86
+ }
87
+ /**
88
+ * Expose the bot_key_id (kid) for DPoP proof headers. Paired with
89
+ * getPrivateKey() so peer modules can construct proofs without
90
+ * threading the key material through their own constructors.
91
+ */
92
+ getKid() {
93
+ return this.kid;
94
+ }
95
+ /**
96
+ * Derive a deterministic session ID from the gateway token.
97
+ * session_id = sha256(token + "proxy").slice(0, 16)
98
+ */
99
+ static deriveSessionId(token) {
100
+ const hash = crypto.createHash("sha256").update(token + "proxy").digest("hex");
101
+ return hash.slice(0, 16);
102
+ }
103
+ /**
104
+ * Send a JSON-RPC 2.0 request to the gateway, handling DPoP auth and
105
+ * nonce retry transparently. A 403 `step_up_required` (deprecated feature,
106
+ * owner ruling 2026-06-10) is no longer polled — it surfaces immediately as
107
+ * a structured tool error via translateHttpError.
108
+ *
109
+ * `signal` (ROUND-3 MAJOR A) is a REAL AbortSignal threaded into the
110
+ * underlying `fetch` option — NOT placed inside `params`/`arguments`. A
111
+ * caller with a per-call timeout budget (e.g. resubscribeMemberships) passes
112
+ * its controller's signal here so a hung backend aborts at the budget instead
113
+ * of hanging forever. Putting the signal in `arguments` (the round-2 bug) both
114
+ * left fetch un-aborted AND serialized a junk `{}` onto the wire body.
115
+ */
116
+ async sendRpc(method, params = {}, signal) {
117
+ const rpcRequest = {
118
+ jsonrpc: "2.0",
119
+ id: ++this.requestCounter,
120
+ method,
121
+ params
122
+ };
123
+ return this.executeWithRetries(rpcRequest, signal);
124
+ }
125
+ async executeWithRetries(rpcRequest, signal) {
126
+ let response;
127
+ try {
128
+ response = await this.sendHttpRequest(rpcRequest, signal);
129
+ } catch (err) {
130
+ return translateNetworkError(err);
131
+ }
132
+ this.trackNonce(response);
133
+ if (response.status === 401) {
134
+ const body = await this.tryParseErrorBody(response);
135
+ if (body?.error === "use_dpop_nonce") {
136
+ console.error("[gateway] Nonce expired, retrying with fresh nonce...");
137
+ try {
138
+ response = await this.sendHttpRequest(rpcRequest, signal);
139
+ } catch (err) {
140
+ return translateNetworkError(err);
141
+ }
142
+ this.trackNonce(response);
143
+ } else {
144
+ const translated = translateHttpError(401, body?.error);
145
+ if (translated) return translated;
146
+ }
147
+ }
148
+ if (response.status === 403) {
149
+ const body = await this.tryParseErrorBody(response);
150
+ const translated = translateHttpError(403, body?.error, body?.trigger);
151
+ if (translated) return translated;
152
+ }
153
+ if (!response.ok) {
154
+ const body = await this.tryParseErrorBody(response);
155
+ const translated = translateHttpError(response.status, body?.error);
156
+ if (translated) return translated;
157
+ return {
158
+ content: [{ type: "text", text: `Gateway error: ${response.status}` }],
159
+ isError: true
160
+ };
161
+ }
162
+ const rpcResponse = await response.json();
163
+ if (rpcResponse.error) {
164
+ return translateJsonRpcError(rpcResponse.error);
165
+ }
166
+ const result = rpcResponse.result;
167
+ return result ?? { content: [{ type: "text", text: "No result" }] };
168
+ }
169
+ async sendHttpRequest(rpcRequest, signal) {
170
+ const proof = await createDPoPProof(
171
+ this.privateKey,
172
+ this.kid,
173
+ "POST",
174
+ this.endpoint,
175
+ this.currentNonce,
176
+ this.token
177
+ );
178
+ return fetch(this.endpoint, {
179
+ method: "POST",
180
+ headers: {
181
+ "Content-Type": "application/json",
182
+ Authorization: `DPoP ${this.token}`,
183
+ DPoP: proof,
184
+ "Mcp-Session-Id": MCP_SESSION_ID
185
+ },
186
+ body: JSON.stringify(rpcRequest),
187
+ // ROUND-3 MAJOR A: the caller's AbortSignal rides HERE (a real fetch
188
+ // option), never inside the JSON-RPC body. `undefined` is a valid value
189
+ // for the fetch `signal` option (no abort wired).
190
+ ...signal ? { signal } : {}
191
+ });
192
+ }
193
+ trackNonce(response) {
194
+ const nonce = response.headers.get("DPoP-Nonce");
195
+ if (nonce) {
196
+ this.currentNonce = nonce;
197
+ }
198
+ }
199
+ async tryParseErrorBody(response) {
200
+ try {
201
+ return await response.json();
202
+ } catch {
203
+ return null;
204
+ }
205
+ }
206
+ };
207
+
208
+ export {
209
+ loadKeystore,
210
+ saveKeystore,
211
+ generateES256KeyPair,
212
+ GatewayClient
213
+ };