kojee-mcp 0.5.3 → 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 +88 -0
- package/dist/chunk-2MIISF2W.js +35 -0
- package/dist/chunk-36L3GCU3.js +213 -0
- package/dist/{chunk-YEC7IHIG.js → chunk-62KH6VNQ.js} +49 -381
- package/dist/{chunk-ZW4SW7LJ.js → chunk-64EOLZNI.js} +14 -5
- package/dist/chunk-HIZ4NDWN.js +141 -0
- package/dist/chunk-LDZXU3DW.js +170 -0
- package/dist/chunk-OSKHA5DS.js +185 -0
- package/dist/{chunk-C6GZ2L2W.js → chunk-X672ZN7V.js} +5 -2
- package/dist/{chunk-WBMX4CHB.js → chunk-YVUXQ4Z2.js} +4 -32
- package/dist/cli.js +37 -9
- package/dist/{codex-stop-hook-JOTBCS5K.js → codex-stop-hook-SWA53ECG.js} +1 -1
- package/dist/control-token-TYDAL477.js +35 -0
- package/dist/{doctor-TSHOMT5X.js → doctor-TXWMMSRC.js} +2 -2
- package/dist/{doctor-codex-BMI5JOO6.js → doctor-codex-3A7KYOVX.js} +10 -3
- package/dist/{hook-server-QF5JVUHV.js → hook-server-NDJSV22J.js} +85 -0
- package/dist/index.js +6 -3
- package/dist/send-cli-7QJ36YY7.js +72 -0
- package/dist/{stop-hook-SEPWWETV.js → stop-hook-GO363SMD.js} +1 -1
- package/dist/{tail-stream-BYKO4DW6.js → tail-stream-U436QL2X.js} +2 -1
- package/dist/webhook-config-UKUSI2FE.js +20 -0
- package/dist/{webhook-sink-7OYZBWXA.js → webhook-sink-GCLL2S6S.js} +12 -3
- package/dist/{wizard-7KHD5JT4.js → wizard-Z5JA3YPV.js} +63 -26
- package/package.json +1 -1
- package/dist/chunk-F7L25L2J.js +0 -60
- package/dist/webhook-config-5TLLX7RA.js +0 -10
package/README.md
CHANGED
|
@@ -280,6 +280,94 @@ never delay or break the Monitor (event-log) or Channel wake paths. The status
|
|
|
280
280
|
log redacts the secret and strips any basic-auth credentials embedded in
|
|
281
281
|
`KOJEE_WEBHOOK_URL`.
|
|
282
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
|
+
|
|
283
371
|
## Development
|
|
284
372
|
|
|
285
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
|
+
};
|