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.
@@ -1,3 +1,6 @@
1
+ import {
2
+ startEventStream
3
+ } from "./chunk-YVUXQ4Z2.js";
1
4
  import {
2
5
  deriveDiscoveryKey,
3
6
  findClaudeAncestorPid
@@ -6,74 +9,26 @@ import {
6
9
  buildCatchUpNote,
7
10
  buildMonitorSpawn,
8
11
  buildReplyRecipe
9
- } from "./chunk-C6GZ2L2W.js";
12
+ } from "./chunk-X672ZN7V.js";
10
13
  import {
11
- MCP_SESSION_ID,
12
- createDPoPProof,
13
- startEventStream
14
- } from "./chunk-WBMX4CHB.js";
14
+ GatewayClient,
15
+ generateES256KeyPair,
16
+ loadKeystore,
17
+ saveKeystore
18
+ } from "./chunk-36L3GCU3.js";
15
19
  import {
16
- secureDir,
17
- secureFile
18
- } from "./chunk-BLEGIR35.js";
20
+ translateToolCallResult
21
+ } from "./chunk-LDZXU3DW.js";
19
22
 
20
23
  // src/index.ts
21
- import fs3 from "fs";
22
- import os2 from "os";
23
- import path3 from "path";
24
+ import fs2 from "fs";
25
+ import os from "os";
26
+ import path2 from "path";
24
27
 
25
28
  // src/auth/auth-module.ts
26
29
  import { calculateJwkThumbprint } from "jose";
27
30
  import crypto from "crypto";
28
31
 
29
- // src/auth/keystore.ts
30
- import { importJWK, exportJWK, generateKeyPair } from "jose";
31
- import fs from "fs";
32
- import os from "os";
33
- import path from "path";
34
- var DEFAULT_PATH = path.join(os.homedir(), ".kojee", "keypair.json");
35
- async function loadKeystore(keystorePath = DEFAULT_PATH, expectedBrokerUrl) {
36
- if (!fs.existsSync(keystorePath)) {
37
- return null;
38
- }
39
- const raw = fs.readFileSync(keystorePath, "utf-8");
40
- const data = JSON.parse(raw);
41
- if (expectedBrokerUrl && data.broker_url !== expectedBrokerUrl) {
42
- return null;
43
- }
44
- const privateKey = await importJWK(data.private_key_jwk, "ES256");
45
- return {
46
- privateKey,
47
- publicJwk: data.public_jwk,
48
- kid: data.kid,
49
- data
50
- };
51
- }
52
- async function saveKeystore(privateKey, publicJwk, kid, brokerUrl, keystorePath = DEFAULT_PATH) {
53
- const dir = path.dirname(keystorePath);
54
- fs.mkdirSync(dir, { recursive: true, mode: 448 });
55
- secureDir(dir);
56
- const privateJwk = await exportJWK(privateKey);
57
- const data = {
58
- private_key_jwk: privateJwk,
59
- kid,
60
- broker_url: brokerUrl,
61
- public_jwk: publicJwk,
62
- enrolled_at: (/* @__PURE__ */ new Date()).toISOString()
63
- };
64
- fs.writeFileSync(keystorePath, JSON.stringify(data, null, 2), {
65
- mode: 384
66
- });
67
- secureFile(keystorePath);
68
- }
69
- async function generateES256KeyPair() {
70
- const { privateKey, publicKey } = await generateKeyPair("ES256");
71
- const publicJwk = await exportJWK(publicKey);
72
- publicJwk.kty = "EC";
73
- publicJwk.crv = "P-256";
74
- return { privateKey, publicJwk };
75
- }
76
-
77
32
  // src/auth/registration.ts
78
33
  async function registerKey(brokerUrl, token, publicJwk) {
79
34
  const url = `${brokerUrl}/api/v1/bots/keys/register/`;
@@ -203,317 +158,6 @@ var AuthModule = class {
203
158
  }
204
159
  };
205
160
 
206
- // src/gateway-client.ts
207
- import crypto2 from "crypto";
208
-
209
- // src/error-translator.ts
210
- function translateGovernanceResult(result) {
211
- const governance = result._meta?.governance;
212
- if (!governance) return result;
213
- if (governance.decision === "deny") {
214
- return formatDenied(governance);
215
- }
216
- if (governance.decision === "require_approval") {
217
- return formatApprovalRequired(governance);
218
- }
219
- return result;
220
- }
221
- function formatApprovalRequired(governance) {
222
- const rules = formatRules(governance.triggered_guardrails);
223
- const text = `APPROVAL REQUIRED: This action needs user approval before executing. Approval ID: ${governance.approval_id}. Expires: ${governance.expires_at ?? "unknown"}. Triggered rules: ${rules}. The user has been notified. Call kojee_check_approval with this approval_id to check the status.`;
224
- return {
225
- content: [{ type: "text", text }],
226
- isError: true
227
- };
228
- }
229
- function formatDenied(governance) {
230
- const rules = formatRules(governance.triggered_guardrails);
231
- const text = `DENIED: This action was blocked by governance policy. Triggered rules: ${rules}. This cannot proceed \u2014 modify your request or ask the user to adjust governance rules.`;
232
- return {
233
- content: [{ type: "text", text }],
234
- isError: true
235
- };
236
- }
237
- function translateHttpError(status, errorCode, trigger) {
238
- if (status === 401) {
239
- if (errorCode === "use_dpop_nonce") {
240
- return null;
241
- }
242
- if (errorCode === "invalid_dpop_proof") {
243
- return makeError(
244
- "Authentication failed. The proxy will attempt to re-enroll. If this persists, regenerate your gateway token."
245
- );
246
- }
247
- if (errorCode === "key_enrollment_required") {
248
- return null;
249
- }
250
- return makeError(
251
- "Gateway token is invalid or expired. Generate a new one."
252
- );
253
- }
254
- if (status === 403 && errorCode === "step_up_required") {
255
- const reason = trigger ? ` (reason: ${trigger})` : "";
256
- return makeError(
257
- `Device re-authorization required${reason}. This action can't proceed until the user re-authorizes this device in the Kojee dashboard.`
258
- );
259
- }
260
- if (status === 429) {
261
- return makeError(
262
- "Rate limit exceeded. Wait before making more requests."
263
- );
264
- }
265
- if (status >= 500) {
266
- return makeError(
267
- "Kojee gateway encountered an error. Try again."
268
- );
269
- }
270
- return null;
271
- }
272
- var TANDEM_ERROR_MESSAGES = {
273
- [-32003]: () => "This Tandem is hardened to owner-only membership; you can't join.",
274
- [-32004]: () => "You aren't a member of that Tandem. Use tandem_join(join_link) first.",
275
- [-32006]: (data) => {
276
- const retry = data?.["retry_after_seconds"] ?? "a moment";
277
- return `Rate limit hit on this Tandem. Retry after ${retry} seconds.`;
278
- },
279
- [-32007]: (data) => {
280
- const rule = data?.["rule"] ?? "policy";
281
- return `Message rejected by Tandem policy (${rule}).`;
282
- },
283
- [-32011]: (data) => {
284
- const id = data?.["tandem_id"] ?? "unknown";
285
- return `Tandem ${id} doesn't exist or isn't visible to you.`;
286
- },
287
- [-32015]: (data) => {
288
- const candidates = data?.["candidates"] ?? [];
289
- return `An @-mention matched multiple members and is ambiguous. Retry with explicit mentions[]. Candidates: ${candidates.join(", ")}.`;
290
- }
291
- };
292
- function translateJsonRpcError(error) {
293
- const msg = error.message ?? "";
294
- const msgLower = msg.toLowerCase();
295
- const tandemMessage = TANDEM_ERROR_MESSAGES[error.code];
296
- if (tandemMessage) {
297
- return {
298
- content: [
299
- {
300
- type: "text",
301
- text: tandemMessage(error.data)
302
- }
303
- ],
304
- isError: true
305
- };
306
- }
307
- switch (error.code) {
308
- case -32601:
309
- return makeError(
310
- `Tool not available. It may have been removed or is not connected. Check your connected services in the Kojee dashboard.`
311
- );
312
- case -32602:
313
- return makeError(msg || "Invalid parameters for this tool call.");
314
- case -32603: {
315
- if (msgLower.includes("multiple accounts connected")) {
316
- return makeError(msg);
317
- }
318
- if (msgLower.includes("not connected")) {
319
- return makeError(
320
- "The service is not connected. Connect it in the Kojee dashboard."
321
- );
322
- }
323
- if (msgLower.includes("scope") && msgLower.includes("access")) {
324
- return makeError(
325
- "Token doesn't have access to this tool. Update scopes in the Kojee dashboard."
326
- );
327
- }
328
- if (msgLower.includes("invalid_grant") || msgLower.includes("token refresh failed") || msgLower.includes("re-authorization") || msgLower.includes("reauthorization")) {
329
- const serviceMatch = msg.match(
330
- /(?:for|connected for)\s+(\w[\w-]*)/i
331
- );
332
- const service = serviceMatch ? serviceMatch[1] : "service";
333
- return makeError(
334
- `The ${service} connection needs re-authorization. Ask the user to reconnect it in the Kojee dashboard.`
335
- );
336
- }
337
- return makeError(msg || "An internal error occurred on the gateway.");
338
- }
339
- case -32600:
340
- return makeError(
341
- "Unexpected response from gateway. This may be a temporary issue."
342
- );
343
- case -32e3:
344
- return makeError(
345
- "Rate limit exceeded. Wait before making more requests."
346
- );
347
- default:
348
- return makeError(msg || "An unknown error occurred.");
349
- }
350
- }
351
- function translateNetworkError(_error) {
352
- return makeError(
353
- "Cannot reach Kojee gateway. Check your connection."
354
- );
355
- }
356
- function translateToolCallResult(result) {
357
- if (result._meta?.governance) {
358
- return translateGovernanceResult(result);
359
- }
360
- return result;
361
- }
362
- function formatRules(guardrails) {
363
- if (!guardrails || guardrails.length === 0) return "unknown";
364
- return guardrails.join(", ");
365
- }
366
- function makeError(text) {
367
- return {
368
- content: [{ type: "text", text }],
369
- isError: true
370
- };
371
- }
372
-
373
- // src/gateway-client.ts
374
- var GatewayClient = class {
375
- constructor(brokerUrl, token, privateKey, kid, sessionId) {
376
- this.brokerUrl = brokerUrl;
377
- this.token = token;
378
- this.privateKey = privateKey;
379
- this.kid = kid;
380
- this.endpoint = `${brokerUrl}/mcp/messages/${sessionId}/`;
381
- }
382
- brokerUrl;
383
- token;
384
- privateKey;
385
- kid;
386
- currentNonce;
387
- requestCounter = 0;
388
- endpoint;
389
- /**
390
- * Expose the DPoP signing key so peer modules sharing auth state
391
- * (e.g. tandem/event-stream.ts) can sign their own requests.
392
- */
393
- getPrivateKey() {
394
- return this.privateKey;
395
- }
396
- /**
397
- * Expose the bot_key_id (kid) for DPoP proof headers. Paired with
398
- * getPrivateKey() so peer modules can construct proofs without
399
- * threading the key material through their own constructors.
400
- */
401
- getKid() {
402
- return this.kid;
403
- }
404
- /**
405
- * Derive a deterministic session ID from the gateway token.
406
- * session_id = sha256(token + "proxy").slice(0, 16)
407
- */
408
- static deriveSessionId(token) {
409
- const hash = crypto2.createHash("sha256").update(token + "proxy").digest("hex");
410
- return hash.slice(0, 16);
411
- }
412
- /**
413
- * Send a JSON-RPC 2.0 request to the gateway, handling DPoP auth and
414
- * nonce retry transparently. A 403 `step_up_required` (deprecated feature,
415
- * owner ruling 2026-06-10) is no longer polled — it surfaces immediately as
416
- * a structured tool error via translateHttpError.
417
- *
418
- * `signal` (ROUND-3 MAJOR A) is a REAL AbortSignal threaded into the
419
- * underlying `fetch` option — NOT placed inside `params`/`arguments`. A
420
- * caller with a per-call timeout budget (e.g. resubscribeMemberships) passes
421
- * its controller's signal here so a hung backend aborts at the budget instead
422
- * of hanging forever. Putting the signal in `arguments` (the round-2 bug) both
423
- * left fetch un-aborted AND serialized a junk `{}` onto the wire body.
424
- */
425
- async sendRpc(method, params = {}, signal) {
426
- const rpcRequest = {
427
- jsonrpc: "2.0",
428
- id: ++this.requestCounter,
429
- method,
430
- params
431
- };
432
- return this.executeWithRetries(rpcRequest, signal);
433
- }
434
- async executeWithRetries(rpcRequest, signal) {
435
- let response;
436
- try {
437
- response = await this.sendHttpRequest(rpcRequest, signal);
438
- } catch (err) {
439
- return translateNetworkError(err);
440
- }
441
- this.trackNonce(response);
442
- if (response.status === 401) {
443
- const body = await this.tryParseErrorBody(response);
444
- if (body?.error === "use_dpop_nonce") {
445
- console.error("[gateway] Nonce expired, retrying with fresh nonce...");
446
- try {
447
- response = await this.sendHttpRequest(rpcRequest, signal);
448
- } catch (err) {
449
- return translateNetworkError(err);
450
- }
451
- this.trackNonce(response);
452
- } else {
453
- const translated = translateHttpError(401, body?.error);
454
- if (translated) return translated;
455
- }
456
- }
457
- if (response.status === 403) {
458
- const body = await this.tryParseErrorBody(response);
459
- const translated = translateHttpError(403, body?.error, body?.trigger);
460
- if (translated) return translated;
461
- }
462
- if (!response.ok) {
463
- const body = await this.tryParseErrorBody(response);
464
- const translated = translateHttpError(response.status, body?.error);
465
- if (translated) return translated;
466
- return {
467
- content: [{ type: "text", text: `Gateway error: ${response.status}` }],
468
- isError: true
469
- };
470
- }
471
- const rpcResponse = await response.json();
472
- if (rpcResponse.error) {
473
- return translateJsonRpcError(rpcResponse.error);
474
- }
475
- const result = rpcResponse.result;
476
- return result ?? { content: [{ type: "text", text: "No result" }] };
477
- }
478
- async sendHttpRequest(rpcRequest, signal) {
479
- const proof = await createDPoPProof(
480
- this.privateKey,
481
- this.kid,
482
- "POST",
483
- this.endpoint,
484
- this.currentNonce,
485
- this.token
486
- );
487
- return fetch(this.endpoint, {
488
- method: "POST",
489
- headers: {
490
- "Content-Type": "application/json",
491
- Authorization: `DPoP ${this.token}`,
492
- DPoP: proof,
493
- "Mcp-Session-Id": MCP_SESSION_ID
494
- },
495
- body: JSON.stringify(rpcRequest),
496
- // ROUND-3 MAJOR A: the caller's AbortSignal rides HERE (a real fetch
497
- // option), never inside the JSON-RPC body. `undefined` is a valid value
498
- // for the fetch `signal` option (no abort wired).
499
- ...signal ? { signal } : {}
500
- });
501
- }
502
- trackNonce(response) {
503
- const nonce = response.headers.get("DPoP-Nonce");
504
- if (nonce) {
505
- this.currentNonce = nonce;
506
- }
507
- }
508
- async tryParseErrorBody(response) {
509
- try {
510
- return await response.json();
511
- } catch {
512
- return null;
513
- }
514
- }
515
- };
516
-
517
161
  // src/tool-registry.ts
518
162
  var ToolRegistry = class {
519
163
  constructor(gateway) {
@@ -590,15 +234,15 @@ import {
590
234
  } from "@modelcontextprotocol/sdk/types.js";
591
235
 
592
236
  // src/version.ts
593
- import fs2 from "fs";
594
- import path2 from "path";
237
+ import fs from "fs";
238
+ import path from "path";
595
239
  import { fileURLToPath } from "url";
596
240
  var FALLBACK_VERSION = "0.0.0-unknown";
597
241
  function resolveVersion() {
598
242
  try {
599
- const here = path2.dirname(fileURLToPath(import.meta.url));
243
+ const here = path.dirname(fileURLToPath(import.meta.url));
600
244
  const parsed = JSON.parse(
601
- fs2.readFileSync(path2.join(here, "..", "package.json"), "utf8")
245
+ fs.readFileSync(path.join(here, "..", "package.json"), "utf8")
602
246
  );
603
247
  return typeof parsed?.version === "string" && parsed.version ? parsed.version : FALLBACK_VERSION;
604
248
  } catch (err) {
@@ -751,7 +395,7 @@ var unknownAdapter = {
751
395
  };
752
396
 
753
397
  // src/index.ts
754
- var DEFAULT_KEYSTORE_PATH = path3.join(os2.homedir(), ".kojee", "keypair.json");
398
+ var DEFAULT_KEYSTORE_PATH = path2.join(os.homedir(), ".kojee", "keypair.json");
755
399
  function isDPoPEnrollmentError(err) {
756
400
  const msg = String(err?.message ?? err ?? "").toLowerCase();
757
401
  if (msg.includes("invalid or expired") && msg.includes("token")) return false;
@@ -804,7 +448,7 @@ async function startProxy(config) {
804
448
  let server;
805
449
  if (adapter.supportsChannels) {
806
450
  const { EventQueue } = await import("./event-queue-5YVJFR3E.js");
807
- const { startHookServer } = await import("./hook-server-QF5JVUHV.js");
451
+ const { startHookServer } = await import("./hook-server-NDJSV22J.js");
808
452
  const {
809
453
  writeDiscoveryByKey,
810
454
  cleanupDiscoveryByKey,
@@ -812,8 +456,8 @@ async function startProxy(config) {
812
456
  } = await import("./session-discovery-FNMJGFPM.js");
813
457
  const { startEventLog, sweepStaleEventLogs } = await import("./event-log-RSTM4PLL.js");
814
458
  const { resubscribeMemberships } = await import("./resubscribe-SLZNA76S.js");
815
- const { resolveWebhookConfig } = await import("./webhook-config-5TLLX7RA.js");
816
- const { createWebhookSink } = await import("./webhook-sink-7OYZBWXA.js");
459
+ const { resolveWebhookConfig } = await import("./webhook-config-UKUSI2FE.js");
460
+ const { createWebhookSink } = await import("./webhook-sink-GCLL2S6S.js");
817
461
  sweepStaleDiscovery();
818
462
  sweepStaleEventLogs();
819
463
  const ccPid = await findClaudeAncestorPid();
@@ -826,6 +470,11 @@ async function startProxy(config) {
826
470
  void eventLog.appendStatus(`status=webhook error="${webhookResolution.error}"`).catch(() => {
827
471
  });
828
472
  }
473
+ if (webhookResolution.warning) {
474
+ console.error(`[kojee-mcp] webhook sink WARNING: ${webhookResolution.warning}`);
475
+ void eventLog.appendStatus(`status=webhook warning="${webhookResolution.warning}"`).catch(() => {
476
+ });
477
+ }
829
478
  const webhookSink = webhookResolution.enabled && webhookResolution.config ? createWebhookSink(webhookResolution.config, {
830
479
  // Route delivery/failure observability to the STATUS sink.
831
480
  log: (line) => {
@@ -839,12 +488,23 @@ async function startProxy(config) {
839
488
  });
840
489
  }
841
490
  server = createMcpServer(registry, adapter, tandemMembershipCount, eventLog.path);
491
+ const { issueControlToken, controlTokenPath } = await import("./control-token-TYDAL477.js");
492
+ let controlToken = null;
493
+ try {
494
+ controlToken = issueControlToken();
495
+ } catch (err) {
496
+ console.error(
497
+ "[kojee-mcp] control token write failed \u2014 POST /send disabled:",
498
+ err.message
499
+ );
500
+ }
842
501
  const queue = new EventQueue();
843
502
  let streamHandle = null;
844
503
  const hookServer = await startHookServer({
845
504
  port: 0,
846
505
  queue,
847
506
  adapter,
507
+ ...controlToken !== null ? { send: { gateway, authToken: controlToken } } : {},
848
508
  getStreamState: () => streamHandle ? streamHandle.getState() : {
849
509
  connected: false,
850
510
  connectedSince: null,
@@ -869,6 +529,9 @@ async function startProxy(config) {
869
529
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
870
530
  brokerUrl: config.url,
871
531
  eventLogPath: eventLog.path,
532
+ // Advertise where the POST /send bearer lives so local consumers
533
+ // (native gateway plugins) can find it without guessing ~/.kojee.
534
+ ...controlToken !== null ? { controlTokenPath: controlTokenPath() } : {},
872
535
  // Stamp the auth mode so `kojee-mcp doctor` renders the pairing check
873
536
  // honestly: a token-mode box has no ~/.kojee/config.json by design and
874
537
  // must not hard-fail on "paired config: MISSING". Defaults to "paired"
@@ -934,8 +597,8 @@ async function startProxy(config) {
934
597
  });
935
598
  } else if (needsWebhookEventStream()) {
936
599
  const { startEventLog, sweepStaleEventLogs } = await import("./event-log-RSTM4PLL.js");
937
- const { resolveWebhookConfig } = await import("./webhook-config-5TLLX7RA.js");
938
- const { createWebhookSink } = await import("./webhook-sink-7OYZBWXA.js");
600
+ const { resolveWebhookConfig } = await import("./webhook-config-UKUSI2FE.js");
601
+ const { createWebhookSink } = await import("./webhook-sink-GCLL2S6S.js");
939
602
  const { resubscribeMemberships } = await import("./resubscribe-SLZNA76S.js");
940
603
  sweepStaleEventLogs();
941
604
  const ccPid = await findClaudeAncestorPid();
@@ -948,6 +611,11 @@ async function startProxy(config) {
948
611
  void eventLog.appendStatus(`status=webhook error="${webhookResolution.error}"`).catch(() => {
949
612
  });
950
613
  }
614
+ if (webhookResolution.warning) {
615
+ console.error(`[kojee-mcp] webhook sink WARNING: ${webhookResolution.warning}`);
616
+ void eventLog.appendStatus(`status=webhook warning="${webhookResolution.warning}"`).catch(() => {
617
+ });
618
+ }
951
619
  const webhookSink = webhookResolution.enabled && webhookResolution.config ? createWebhookSink(webhookResolution.config, {
952
620
  log: (line) => {
953
621
  void eventLog.appendStatus(`status=webhook ${line}`).catch(() => {
@@ -1021,7 +689,7 @@ async function enrollAndDiscover(config, keystorePath, isRetry = false) {
1021
689
  "[kojee-mcp] Auth failed, attempting recovery with fresh enrollment..."
1022
690
  );
1023
691
  try {
1024
- if (fs3.existsSync(keystorePath)) fs3.unlinkSync(keystorePath);
692
+ if (fs2.existsSync(keystorePath)) fs2.unlinkSync(keystorePath);
1025
693
  } catch (unlinkErr) {
1026
694
  console.error("[kojee-mcp] Could not remove stale keystore:", unlinkErr);
1027
695
  }
@@ -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({ webhookUrl, webhookSecret });
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,