kojee-mcp 0.5.3 → 0.5.6
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 +112 -5
- package/dist/{chunk-YEC7IHIG.js → chunk-2BDAM3TH.js} +92 -523
- package/dist/chunk-2MIISF2W.js +35 -0
- package/dist/chunk-3XDJOHMZ.js +223 -0
- package/dist/{chunk-ZW4SW7LJ.js → chunk-64EOLZNI.js} +14 -5
- package/dist/chunk-6SK6ITFE.js +142 -0
- package/dist/chunk-GI2CKKBL.js +46 -0
- package/dist/chunk-HIZ4NDWN.js +141 -0
- package/dist/chunk-LDZXU3DW.js +170 -0
- package/dist/{resubscribe-SLZNA76S.js → chunk-OT2GILXC.js} +1 -0
- package/dist/{chunk-WBMX4CHB.js → chunk-UEGQGXPY.js} +57 -40
- package/dist/chunk-V5VZPYMZ.js +185 -0
- package/dist/{chunk-C6GZ2L2W.js → chunk-X672ZN7V.js} +5 -2
- package/dist/cli.js +47 -24
- package/dist/{codex-stop-hook-JOTBCS5K.js → codex-stop-hook-SWA53ECG.js} +1 -1
- package/dist/control-token-4BUCTYQB.js +13 -0
- package/dist/{doctor-TSHOMT5X.js → doctor-QCQDFLEH.js} +30 -17
- package/dist/{doctor-codex-BMI5JOO6.js → doctor-codex-NZ53ROQA.js} +12 -5
- package/dist/ensure-join-7AEDJMPE.js +96 -0
- package/dist/gateway-client-93P1E0CZ.d.ts +92 -0
- package/dist/{hook-server-QF5JVUHV.js → hook-server-37E2LUKJ.js} +91 -0
- package/dist/index.d.ts +18 -15
- package/dist/index.js +9 -3
- package/dist/lib.d.ts +427 -0
- package/dist/lib.js +44 -0
- package/dist/reconnect-scheduler-JSXCJKQP.js +26 -0
- package/dist/resubscribe-G5OGDZJD.js +6 -0
- package/dist/send-cli-C2F4WTBN.js +72 -0
- package/dist/{stop-hook-SEPWWETV.js → stop-hook-TRAMQYNE.js} +16 -8
- package/dist/{tail-stream-BYKO4DW6.js → tail-stream-VUZBYKXS.js} +4 -3
- package/dist/{user-prompt-submit-hook-ARPEO6FF.js → user-prompt-submit-hook-ZD2XKN7U.js} +7 -1
- package/dist/webhook-config-O4WMQ532.js +20 -0
- package/dist/{webhook-sink-7OYZBWXA.js → webhook-sink-NWGCUDGY.js} +28 -5
- package/dist/{wizard-7KHD5JT4.js → wizard-OSOAY4GO.js} +64 -27
- package/package.json +11 -2
- package/dist/chunk-F7L25L2J.js +0 -60
- package/dist/webhook-config-5TLLX7RA.js +0 -10
|
@@ -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,223 @@
|
|
|
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 crypto from "crypto";
|
|
18
|
+
import fs from "fs";
|
|
19
|
+
import os from "os";
|
|
20
|
+
import path from "path";
|
|
21
|
+
var DEFAULT_PATH = path.join(os.homedir(), ".kojee", "keypair.json");
|
|
22
|
+
function defaultPairedKeystorePath() {
|
|
23
|
+
return path.join(os.homedir(), ".kojee", "keypair.json");
|
|
24
|
+
}
|
|
25
|
+
function deriveKeystorePath(token) {
|
|
26
|
+
const hash = crypto.createHash("sha256").update(token).digest("hex").slice(0, 12);
|
|
27
|
+
return path.join(os.homedir(), ".kojee", `keypair-${hash}.json`);
|
|
28
|
+
}
|
|
29
|
+
async function loadKeystore(keystorePath = DEFAULT_PATH, expectedBrokerUrl) {
|
|
30
|
+
if (!fs.existsSync(keystorePath)) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const raw = fs.readFileSync(keystorePath, "utf-8");
|
|
34
|
+
const data = JSON.parse(raw);
|
|
35
|
+
if (expectedBrokerUrl && data.broker_url !== expectedBrokerUrl) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const privateKey = await importJWK(data.private_key_jwk, "ES256");
|
|
39
|
+
return {
|
|
40
|
+
privateKey,
|
|
41
|
+
publicJwk: data.public_jwk,
|
|
42
|
+
kid: data.kid,
|
|
43
|
+
data
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
async function saveKeystore(privateKey, publicJwk, kid, brokerUrl, keystorePath = DEFAULT_PATH) {
|
|
47
|
+
const dir = path.dirname(keystorePath);
|
|
48
|
+
fs.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
49
|
+
secureDir(dir);
|
|
50
|
+
const privateJwk = await exportJWK(privateKey);
|
|
51
|
+
const data = {
|
|
52
|
+
private_key_jwk: privateJwk,
|
|
53
|
+
kid,
|
|
54
|
+
broker_url: brokerUrl,
|
|
55
|
+
public_jwk: publicJwk,
|
|
56
|
+
enrolled_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
57
|
+
};
|
|
58
|
+
fs.writeFileSync(keystorePath, JSON.stringify(data, null, 2), {
|
|
59
|
+
mode: 384
|
|
60
|
+
});
|
|
61
|
+
secureFile(keystorePath);
|
|
62
|
+
}
|
|
63
|
+
async function generateES256KeyPair() {
|
|
64
|
+
const { privateKey, publicKey } = await generateKeyPair("ES256");
|
|
65
|
+
const publicJwk = await exportJWK(publicKey);
|
|
66
|
+
publicJwk.kty = "EC";
|
|
67
|
+
publicJwk.crv = "P-256";
|
|
68
|
+
return { privateKey, publicJwk };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// src/gateway-client.ts
|
|
72
|
+
import crypto2 from "crypto";
|
|
73
|
+
var GatewayClient = class {
|
|
74
|
+
constructor(brokerUrl, token, privateKey, kid, sessionId) {
|
|
75
|
+
this.brokerUrl = brokerUrl;
|
|
76
|
+
this.token = token;
|
|
77
|
+
this.privateKey = privateKey;
|
|
78
|
+
this.kid = kid;
|
|
79
|
+
this.endpoint = `${brokerUrl}/mcp/messages/${sessionId}/`;
|
|
80
|
+
}
|
|
81
|
+
brokerUrl;
|
|
82
|
+
token;
|
|
83
|
+
privateKey;
|
|
84
|
+
kid;
|
|
85
|
+
currentNonce;
|
|
86
|
+
requestCounter = 0;
|
|
87
|
+
endpoint;
|
|
88
|
+
/**
|
|
89
|
+
* Expose the DPoP signing key so peer modules sharing auth state
|
|
90
|
+
* (e.g. tandem/event-stream.ts) can sign their own requests.
|
|
91
|
+
*/
|
|
92
|
+
getPrivateKey() {
|
|
93
|
+
return this.privateKey;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Expose the bot_key_id (kid) for DPoP proof headers. Paired with
|
|
97
|
+
* getPrivateKey() so peer modules can construct proofs without
|
|
98
|
+
* threading the key material through their own constructors.
|
|
99
|
+
*/
|
|
100
|
+
getKid() {
|
|
101
|
+
return this.kid;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Derive a deterministic session ID from the gateway token.
|
|
105
|
+
* session_id = sha256(token + "proxy").slice(0, 16)
|
|
106
|
+
*/
|
|
107
|
+
static deriveSessionId(token) {
|
|
108
|
+
const hash = crypto2.createHash("sha256").update(token + "proxy").digest("hex");
|
|
109
|
+
return hash.slice(0, 16);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Send a JSON-RPC 2.0 request to the gateway, handling DPoP auth and
|
|
113
|
+
* nonce retry transparently. A 403 `step_up_required` (deprecated feature,
|
|
114
|
+
* owner ruling 2026-06-10) is no longer polled — it surfaces immediately as
|
|
115
|
+
* a structured tool error via translateHttpError.
|
|
116
|
+
*
|
|
117
|
+
* `signal` (ROUND-3 MAJOR A) is a REAL AbortSignal threaded into the
|
|
118
|
+
* underlying `fetch` option — NOT placed inside `params`/`arguments`. A
|
|
119
|
+
* caller with a per-call timeout budget (e.g. resubscribeMemberships) passes
|
|
120
|
+
* its controller's signal here so a hung backend aborts at the budget instead
|
|
121
|
+
* of hanging forever. Putting the signal in `arguments` (the round-2 bug) both
|
|
122
|
+
* left fetch un-aborted AND serialized a junk `{}` onto the wire body.
|
|
123
|
+
*/
|
|
124
|
+
async sendRpc(method, params = {}, signal) {
|
|
125
|
+
const rpcRequest = {
|
|
126
|
+
jsonrpc: "2.0",
|
|
127
|
+
id: ++this.requestCounter,
|
|
128
|
+
method,
|
|
129
|
+
params
|
|
130
|
+
};
|
|
131
|
+
return this.executeWithRetries(rpcRequest, signal);
|
|
132
|
+
}
|
|
133
|
+
async executeWithRetries(rpcRequest, signal) {
|
|
134
|
+
let response;
|
|
135
|
+
try {
|
|
136
|
+
response = await this.sendHttpRequest(rpcRequest, signal);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
return translateNetworkError(err);
|
|
139
|
+
}
|
|
140
|
+
this.trackNonce(response);
|
|
141
|
+
if (response.status === 401) {
|
|
142
|
+
const body = await this.tryParseErrorBody(response);
|
|
143
|
+
if (body?.error === "use_dpop_nonce") {
|
|
144
|
+
console.error("[gateway] Nonce expired, retrying with fresh nonce...");
|
|
145
|
+
try {
|
|
146
|
+
response = await this.sendHttpRequest(rpcRequest, signal);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
return translateNetworkError(err);
|
|
149
|
+
}
|
|
150
|
+
this.trackNonce(response);
|
|
151
|
+
} else {
|
|
152
|
+
const translated = translateHttpError(401, body?.error);
|
|
153
|
+
if (translated) return translated;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (response.status === 403) {
|
|
157
|
+
const body = await this.tryParseErrorBody(response);
|
|
158
|
+
const translated = translateHttpError(403, body?.error, body?.trigger);
|
|
159
|
+
if (translated) return translated;
|
|
160
|
+
}
|
|
161
|
+
if (!response.ok) {
|
|
162
|
+
const body = await this.tryParseErrorBody(response);
|
|
163
|
+
const translated = translateHttpError(response.status, body?.error);
|
|
164
|
+
if (translated) return translated;
|
|
165
|
+
return {
|
|
166
|
+
content: [{ type: "text", text: `Gateway error: ${response.status}` }],
|
|
167
|
+
isError: true
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
const rpcResponse = await response.json();
|
|
171
|
+
if (rpcResponse.error) {
|
|
172
|
+
return translateJsonRpcError(rpcResponse.error);
|
|
173
|
+
}
|
|
174
|
+
const result = rpcResponse.result;
|
|
175
|
+
return result ?? { content: [{ type: "text", text: "No result" }] };
|
|
176
|
+
}
|
|
177
|
+
async sendHttpRequest(rpcRequest, signal) {
|
|
178
|
+
const proof = await createDPoPProof(
|
|
179
|
+
this.privateKey,
|
|
180
|
+
this.kid,
|
|
181
|
+
"POST",
|
|
182
|
+
this.endpoint,
|
|
183
|
+
this.currentNonce,
|
|
184
|
+
this.token
|
|
185
|
+
);
|
|
186
|
+
return fetch(this.endpoint, {
|
|
187
|
+
method: "POST",
|
|
188
|
+
headers: {
|
|
189
|
+
"Content-Type": "application/json",
|
|
190
|
+
Authorization: `DPoP ${this.token}`,
|
|
191
|
+
DPoP: proof,
|
|
192
|
+
"Mcp-Session-Id": MCP_SESSION_ID
|
|
193
|
+
},
|
|
194
|
+
body: JSON.stringify(rpcRequest),
|
|
195
|
+
// ROUND-3 MAJOR A: the caller's AbortSignal rides HERE (a real fetch
|
|
196
|
+
// option), never inside the JSON-RPC body. `undefined` is a valid value
|
|
197
|
+
// for the fetch `signal` option (no abort wired).
|
|
198
|
+
...signal ? { signal } : {}
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
trackNonce(response) {
|
|
202
|
+
const nonce = response.headers.get("DPoP-Nonce");
|
|
203
|
+
if (nonce) {
|
|
204
|
+
this.currentNonce = nonce;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async tryParseErrorBody(response) {
|
|
208
|
+
try {
|
|
209
|
+
return await response.json();
|
|
210
|
+
} catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
export {
|
|
217
|
+
defaultPairedKeystorePath,
|
|
218
|
+
deriveKeystorePath,
|
|
219
|
+
loadKeystore,
|
|
220
|
+
saveKeystore,
|
|
221
|
+
generateES256KeyPair,
|
|
222
|
+
GatewayClient
|
|
223
|
+
};
|
|
@@ -24,7 +24,9 @@ function buildCodexMcpServerTable(opts) {
|
|
|
24
24
|
"[mcp_servers.kojee.env]",
|
|
25
25
|
'KOJEE_RUNTIME = "codex"',
|
|
26
26
|
`KOJEE_WEBHOOK_URL = "${escapeTomlString(opts.webhookUrl)}"`,
|
|
27
|
-
`KOJEE_WEBHOOK_SECRET = "${escapeTomlString(opts.webhookSecret)}"
|
|
27
|
+
`KOJEE_WEBHOOK_SECRET = "${escapeTomlString(opts.webhookSecret)}"`,
|
|
28
|
+
// Signature emission overrides (0.5.3) — emitted only when configured.
|
|
29
|
+
...(opts.signatureEnv ?? []).map(([k, v]) => `${k} = "${escapeTomlString(v)}"`)
|
|
28
30
|
].join("\n");
|
|
29
31
|
}
|
|
30
32
|
function buildCodexStopHookBlock() {
|
|
@@ -48,7 +50,7 @@ function writeCodexConfig(inputs) {
|
|
|
48
50
|
toml = fs.readFileSync(configPath, "utf8");
|
|
49
51
|
} catch {
|
|
50
52
|
}
|
|
51
|
-
toml = upsertKojeeTomlTables(toml, inputs.webhookUrl, inputs.webhookSecret);
|
|
53
|
+
toml = upsertKojeeTomlTables(toml, inputs.webhookUrl, inputs.webhookSecret, inputs.signatureEnv ?? []);
|
|
52
54
|
writeFile600(configPath, toml);
|
|
53
55
|
const hooks = readJson(hooksPath);
|
|
54
56
|
hooks.hooks ??= {};
|
|
@@ -93,10 +95,14 @@ function removeCodexConfig(opts = {}) {
|
|
|
93
95
|
}
|
|
94
96
|
return result;
|
|
95
97
|
}
|
|
96
|
-
function upsertKojeeTomlTables(existing, webhookUrl, webhookSecret) {
|
|
98
|
+
function upsertKojeeTomlTables(existing, webhookUrl, webhookSecret, signatureEnv = []) {
|
|
97
99
|
const parsed = extractKojeeBlock(existing);
|
|
98
100
|
if (!parsed) {
|
|
99
|
-
const block = buildCodexMcpServerTable({
|
|
101
|
+
const block = buildCodexMcpServerTable({
|
|
102
|
+
webhookUrl,
|
|
103
|
+
webhookSecret,
|
|
104
|
+
...signatureEnv.length > 0 ? { signatureEnv } : {}
|
|
105
|
+
});
|
|
100
106
|
const base2 = existing.replace(/\s*$/, "");
|
|
101
107
|
return base2.length === 0 ? block + "\n" : base2 + "\n\n" + block + "\n";
|
|
102
108
|
}
|
|
@@ -107,7 +113,10 @@ function upsertKojeeTomlTables(existing, webhookUrl, webhookSecret) {
|
|
|
107
113
|
const envKeys = upsertKeyLines(parsed.envKeys, [
|
|
108
114
|
["KOJEE_RUNTIME", '"codex"'],
|
|
109
115
|
["KOJEE_WEBHOOK_URL", `"${escapeTomlString(webhookUrl)}"`],
|
|
110
|
-
["KOJEE_WEBHOOK_SECRET", `"${escapeTomlString(webhookSecret)}"`]
|
|
116
|
+
["KOJEE_WEBHOOK_SECRET", `"${escapeTomlString(webhookSecret)}"`],
|
|
117
|
+
// Signature emission overrides (0.5.3) — owned only when configured this
|
|
118
|
+
// run; an operator's existing signature lines are otherwise preserved.
|
|
119
|
+
...signatureEnv.map(([k, v]) => [k, `"${escapeTomlString(v)}"`])
|
|
111
120
|
]);
|
|
112
121
|
const merged = [
|
|
113
122
|
KOJEE_TABLE_HEADER,
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import {
|
|
2
|
+
generateES256KeyPair,
|
|
3
|
+
loadKeystore,
|
|
4
|
+
saveKeystore
|
|
5
|
+
} from "./chunk-3XDJOHMZ.js";
|
|
6
|
+
|
|
7
|
+
// src/auth/auth-module.ts
|
|
8
|
+
import { calculateJwkThumbprint } from "jose";
|
|
9
|
+
import crypto from "crypto";
|
|
10
|
+
|
|
11
|
+
// src/auth/registration.ts
|
|
12
|
+
async function registerKey(brokerUrl, token, publicJwk) {
|
|
13
|
+
const url = `${brokerUrl}/api/v1/bots/keys/register/`;
|
|
14
|
+
const response = await fetch(url, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: {
|
|
17
|
+
"Content-Type": "application/json",
|
|
18
|
+
Authorization: `Bearer ${token}`
|
|
19
|
+
},
|
|
20
|
+
body: JSON.stringify({ public_jwk: publicJwk })
|
|
21
|
+
});
|
|
22
|
+
if (!response.ok) {
|
|
23
|
+
const body = await response.text();
|
|
24
|
+
throw new Error(
|
|
25
|
+
`Key registration failed (${response.status}): ${body}`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
return await response.json();
|
|
29
|
+
}
|
|
30
|
+
async function confirmKey(brokerUrl, token, botKeyId, challenge, signature) {
|
|
31
|
+
const url = `${brokerUrl}/api/v1/bots/keys/confirm/`;
|
|
32
|
+
const response = await fetch(url, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: {
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
Authorization: `Bearer ${token}`
|
|
37
|
+
},
|
|
38
|
+
body: JSON.stringify({
|
|
39
|
+
bot_key_id: botKeyId,
|
|
40
|
+
challenge,
|
|
41
|
+
signature
|
|
42
|
+
})
|
|
43
|
+
});
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
const body = await response.text();
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Key confirmation failed (${response.status}): ${body}`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return await response.json();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/auth/auth-module.ts
|
|
54
|
+
async function signChallengeRaw(privateKey, data) {
|
|
55
|
+
const signer = crypto.createSign("SHA256");
|
|
56
|
+
signer.update(data);
|
|
57
|
+
signer.end();
|
|
58
|
+
const derSignature = signer.sign(
|
|
59
|
+
privateKey
|
|
60
|
+
);
|
|
61
|
+
return derSignature.toString("base64url");
|
|
62
|
+
}
|
|
63
|
+
var AuthModule = class {
|
|
64
|
+
constructor(token, brokerUrl, keystorePath) {
|
|
65
|
+
this.token = token;
|
|
66
|
+
this.brokerUrl = brokerUrl;
|
|
67
|
+
this.keystorePath = keystorePath;
|
|
68
|
+
}
|
|
69
|
+
token;
|
|
70
|
+
brokerUrl;
|
|
71
|
+
keystorePath;
|
|
72
|
+
privateKey = null;
|
|
73
|
+
publicJwk = null;
|
|
74
|
+
kid = null;
|
|
75
|
+
/**
|
|
76
|
+
* Ensure we have an enrolled keypair. Either loads from disk or
|
|
77
|
+
* performs the full enrollment flow.
|
|
78
|
+
*/
|
|
79
|
+
async ensureEnrolled() {
|
|
80
|
+
const existing = await loadKeystore(this.keystorePath, this.brokerUrl);
|
|
81
|
+
if (existing) {
|
|
82
|
+
this.privateKey = existing.privateKey;
|
|
83
|
+
this.publicJwk = existing.publicJwk;
|
|
84
|
+
this.kid = existing.kid;
|
|
85
|
+
console.error("[auth] Loaded existing keypair from keystore");
|
|
86
|
+
return {
|
|
87
|
+
privateKey: existing.privateKey,
|
|
88
|
+
publicJwk: existing.publicJwk,
|
|
89
|
+
kid: existing.kid
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
console.error("[auth] No valid keystore found, enrolling new keypair...");
|
|
93
|
+
const { privateKey, publicJwk } = await generateES256KeyPair();
|
|
94
|
+
const regResult = await registerKey(this.brokerUrl, this.token, publicJwk);
|
|
95
|
+
console.error(`[auth] Key registered: ${regResult.bot_key_id}`);
|
|
96
|
+
const thumbprint = await calculateJwkThumbprint(publicJwk, "sha256");
|
|
97
|
+
const challengeData = `${regResult.challenge}.${thumbprint}`;
|
|
98
|
+
const signature = await signChallengeRaw(privateKey, challengeData);
|
|
99
|
+
const confirmResult = await confirmKey(
|
|
100
|
+
this.brokerUrl,
|
|
101
|
+
this.token,
|
|
102
|
+
regResult.bot_key_id,
|
|
103
|
+
regResult.challenge,
|
|
104
|
+
signature
|
|
105
|
+
);
|
|
106
|
+
if (!confirmResult.key_confirmed) {
|
|
107
|
+
throw new Error("Key enrollment failed: confirmation was rejected");
|
|
108
|
+
}
|
|
109
|
+
console.error("[auth] Key enrollment confirmed");
|
|
110
|
+
await saveKeystore(
|
|
111
|
+
privateKey,
|
|
112
|
+
publicJwk,
|
|
113
|
+
regResult.bot_key_id,
|
|
114
|
+
this.brokerUrl,
|
|
115
|
+
this.keystorePath
|
|
116
|
+
);
|
|
117
|
+
this.privateKey = privateKey;
|
|
118
|
+
this.publicJwk = publicJwk;
|
|
119
|
+
this.kid = regResult.bot_key_id;
|
|
120
|
+
return {
|
|
121
|
+
privateKey,
|
|
122
|
+
publicJwk,
|
|
123
|
+
kid: regResult.bot_key_id
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
getPrivateKey() {
|
|
127
|
+
if (!this.privateKey) throw new Error("Not enrolled yet");
|
|
128
|
+
return this.privateKey;
|
|
129
|
+
}
|
|
130
|
+
getPublicJwk() {
|
|
131
|
+
if (!this.publicJwk) throw new Error("Not enrolled yet");
|
|
132
|
+
return this.publicJwk;
|
|
133
|
+
}
|
|
134
|
+
getKid() {
|
|
135
|
+
if (!this.kid) throw new Error("Not enrolled yet");
|
|
136
|
+
return this.kid;
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export {
|
|
141
|
+
AuthModule
|
|
142
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {
|
|
2
|
+
secureDir,
|
|
3
|
+
secureFile
|
|
4
|
+
} from "./chunk-BLEGIR35.js";
|
|
5
|
+
|
|
6
|
+
// src/tandem/control-token.ts
|
|
7
|
+
import crypto from "crypto";
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import os from "os";
|
|
10
|
+
import path from "path";
|
|
11
|
+
function controlTokenPath(kojeeDir = path.join(os.homedir(), ".kojee")) {
|
|
12
|
+
return path.join(kojeeDir, "control-token");
|
|
13
|
+
}
|
|
14
|
+
function issueControlToken(filePath = controlTokenPath()) {
|
|
15
|
+
const token = crypto.randomBytes(32).toString("hex");
|
|
16
|
+
const dir = path.dirname(filePath);
|
|
17
|
+
fs.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
18
|
+
secureDir(dir);
|
|
19
|
+
try {
|
|
20
|
+
fs.unlinkSync(filePath);
|
|
21
|
+
} catch (err) {
|
|
22
|
+
if (err.code !== "ENOENT") throw err;
|
|
23
|
+
}
|
|
24
|
+
fs.writeFileSync(filePath, token + "\n", { mode: 384, flag: "wx" });
|
|
25
|
+
secureFile(filePath);
|
|
26
|
+
return token;
|
|
27
|
+
}
|
|
28
|
+
function loadControlToken(filePath = controlTokenPath()) {
|
|
29
|
+
try {
|
|
30
|
+
const raw = fs.readFileSync(filePath, "utf8").trim();
|
|
31
|
+
return raw.length > 0 ? raw : null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function controlTokenAuthHeaders(filePath) {
|
|
37
|
+
const token = loadControlToken(filePath);
|
|
38
|
+
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export {
|
|
42
|
+
controlTokenPath,
|
|
43
|
+
issueControlToken,
|
|
44
|
+
loadControlToken,
|
|
45
|
+
controlTokenAuthHeaders
|
|
46
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import {
|
|
2
|
+
translateToolCallResult
|
|
3
|
+
} from "./chunk-LDZXU3DW.js";
|
|
4
|
+
|
|
5
|
+
// src/tandem/send.ts
|
|
6
|
+
var SEND_KINDS = ["message", "status"];
|
|
7
|
+
function sendFailure(error, message) {
|
|
8
|
+
return { ok: false, error, message };
|
|
9
|
+
}
|
|
10
|
+
function parseSendRequest(input) {
|
|
11
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
12
|
+
return sendFailure("bad_request", "request must be a JSON object");
|
|
13
|
+
}
|
|
14
|
+
const obj = input;
|
|
15
|
+
const tandemId = obj["tandem_id"];
|
|
16
|
+
if (typeof tandemId !== "string" || tandemId.length === 0) {
|
|
17
|
+
return sendFailure("bad_request", "tandem_id is required and must be a non-empty string");
|
|
18
|
+
}
|
|
19
|
+
const body = obj["body"];
|
|
20
|
+
if (typeof body !== "string" || body.length === 0) {
|
|
21
|
+
return sendFailure("bad_request", "body is required and must be a non-empty string");
|
|
22
|
+
}
|
|
23
|
+
const request = { tandem_id: tandemId, body };
|
|
24
|
+
if (obj["reply_to"] !== void 0) {
|
|
25
|
+
if (typeof obj["reply_to"] !== "string" || obj["reply_to"].length === 0) {
|
|
26
|
+
return sendFailure("bad_request", "reply_to must be a non-empty string when present");
|
|
27
|
+
}
|
|
28
|
+
request.reply_to = obj["reply_to"];
|
|
29
|
+
}
|
|
30
|
+
if (obj["kind"] !== void 0) {
|
|
31
|
+
const kind = obj["kind"];
|
|
32
|
+
if (typeof kind !== "string" || !SEND_KINDS.includes(kind)) {
|
|
33
|
+
return sendFailure(
|
|
34
|
+
"bad_request",
|
|
35
|
+
`kind must be one of: ${SEND_KINDS.join(", ")}`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
request.kind = kind;
|
|
39
|
+
}
|
|
40
|
+
return { ok: true, request };
|
|
41
|
+
}
|
|
42
|
+
function classifySendFailure(text) {
|
|
43
|
+
if (/member[\s_-]?cap/i.test(text)) {
|
|
44
|
+
return sendFailure("member_cap", text);
|
|
45
|
+
}
|
|
46
|
+
if (/gateway error:\s*403/i.test(text) || /mcp_request_blocked/i.test(text) || /blocked by a firewall/i.test(text) || /security service/i.test(text)) {
|
|
47
|
+
return sendFailure(
|
|
48
|
+
"content_blocked",
|
|
49
|
+
`content blocked by gateway \u2014 the gateway's firewall rejected the message content (commonly a literal URL in the body). De-fang or split the content and retry. Gateway said: ${text}`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (/token is invalid or expired/i.test(text) || /authentication failed/i.test(text) || /re-?authoriz/i.test(text)) {
|
|
53
|
+
return sendFailure("gateway_auth", text);
|
|
54
|
+
}
|
|
55
|
+
if (/aren'?t a member|not a member/i.test(text)) {
|
|
56
|
+
return sendFailure("not_member", text);
|
|
57
|
+
}
|
|
58
|
+
if (/rate limit/i.test(text)) {
|
|
59
|
+
return sendFailure("rate_limited", text);
|
|
60
|
+
}
|
|
61
|
+
if (/cannot reach kojee gateway/i.test(text)) {
|
|
62
|
+
return sendFailure("network", text);
|
|
63
|
+
}
|
|
64
|
+
if (/^DENIED:/.test(text)) {
|
|
65
|
+
return sendFailure("governance_denied", text);
|
|
66
|
+
}
|
|
67
|
+
if (/^APPROVAL REQUIRED:/.test(text)) {
|
|
68
|
+
return sendFailure("approval_required", text);
|
|
69
|
+
}
|
|
70
|
+
return sendFailure("send_failed", text);
|
|
71
|
+
}
|
|
72
|
+
async function executeSend(gateway, request) {
|
|
73
|
+
const args = {
|
|
74
|
+
tandem_id: request.tandem_id,
|
|
75
|
+
body: request.body,
|
|
76
|
+
...request.reply_to !== void 0 ? { reply_to: request.reply_to } : {},
|
|
77
|
+
...request.kind !== void 0 ? { kind: request.kind } : {}
|
|
78
|
+
};
|
|
79
|
+
let result;
|
|
80
|
+
try {
|
|
81
|
+
result = await gateway.sendRpc("tools/call", {
|
|
82
|
+
name: "tandem_send",
|
|
83
|
+
arguments: args
|
|
84
|
+
});
|
|
85
|
+
} catch (err) {
|
|
86
|
+
return classifySendFailure(
|
|
87
|
+
`tandem_send failed: ${err?.message ?? String(err)}`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
const translated = translateToolCallResult(result);
|
|
91
|
+
const text = (translated.content ?? []).map((c) => typeof c?.text === "string" ? c.text : "").filter(Boolean).join("\n");
|
|
92
|
+
if (translated.isError) {
|
|
93
|
+
return classifySendFailure(text || "tandem_send returned an error with no text");
|
|
94
|
+
}
|
|
95
|
+
let messageId = null;
|
|
96
|
+
let cursor = null;
|
|
97
|
+
try {
|
|
98
|
+
const parsed = JSON.parse(text);
|
|
99
|
+
const rawId = parsed["message_id"] ?? parsed["id"];
|
|
100
|
+
if (rawId !== void 0 && rawId !== null) messageId = String(rawId);
|
|
101
|
+
const rawCursor = parsed["cursor"];
|
|
102
|
+
if (typeof rawCursor === "number") cursor = rawCursor;
|
|
103
|
+
} catch {
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
tandem_id: request.tandem_id,
|
|
108
|
+
message_id: messageId,
|
|
109
|
+
cursor,
|
|
110
|
+
text
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
var HTTP_STATUS_BY_CODE = {
|
|
114
|
+
bad_request: 400,
|
|
115
|
+
unauthorized: 401,
|
|
116
|
+
content_blocked: 403,
|
|
117
|
+
member_cap: 403,
|
|
118
|
+
not_member: 403,
|
|
119
|
+
governance_denied: 403,
|
|
120
|
+
approval_required: 403,
|
|
121
|
+
rate_limited: 429,
|
|
122
|
+
// Upstream/credential problems — the LOCAL surface worked, the gateway leg
|
|
123
|
+
// failed: a gateway (502) class from the caller's point of view.
|
|
124
|
+
not_paired: 502,
|
|
125
|
+
not_enrolled: 502,
|
|
126
|
+
gateway_auth: 502,
|
|
127
|
+
network: 502,
|
|
128
|
+
send_failed: 502,
|
|
129
|
+
send_unavailable: 503
|
|
130
|
+
};
|
|
131
|
+
function httpStatusForEnvelope(envelope) {
|
|
132
|
+
if (envelope.ok) return 200;
|
|
133
|
+
return HTTP_STATUS_BY_CODE[envelope.error] ?? 502;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export {
|
|
137
|
+
sendFailure,
|
|
138
|
+
parseSendRequest,
|
|
139
|
+
executeSend,
|
|
140
|
+
httpStatusForEnvelope
|
|
141
|
+
};
|