@kalera/munin-runtime 1.2.5 → 1.2.8

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,4 +1,4 @@
1
1
 
2
- > @kalera/munin-runtime@1.2.5 build /home/runner/work/munin-for-agents/munin-for-agents/packages/runtime
2
+ > @kalera/munin-runtime@1.2.8 build /home/runner/work/munin-for-agents/munin-for-agents/packages/runtime
3
3
  > tsc -p tsconfig.json
4
4
 
package/dist/env.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ export { parseCliArgs, loadCliEnv, resolveProjectId, executeWithRetry, safeError, } from "./index.js";
2
+ export type { ParsedCliArgs, CliEnv } from "./index.js";
1
3
  export interface EnvVar {
2
4
  key: string;
3
5
  value: string;
@@ -10,5 +12,6 @@ export declare function resolveEncryptionKey(): string | undefined;
10
12
  * Upsert key=value pairs into a .env file.
11
13
  * - Existing keys are replaced.
12
14
  * - New keys are appended.
15
+ * - Keys are matched via string split (not regex) to prevent injection.
13
16
  */
14
17
  export declare function writeEnvFile(filename: string, vars: EnvVar[], cwd?: string): void;
package/dist/env.js CHANGED
@@ -1,34 +1,65 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
+ // Re-export all shared utilities from index.ts (single source of truth)
4
+ export { parseCliArgs, loadCliEnv, resolveProjectId, executeWithRetry, safeError, } from "./index.js";
3
5
  /**
4
6
  * Resolve the encryption key for E2EE projects from MUNIN_ENCRYPTION_KEY env var.
5
7
  */
6
8
  export function resolveEncryptionKey() {
7
9
  return process.env.MUNIN_ENCRYPTION_KEY;
8
10
  }
11
+ /**
12
+ * Escape shell/metacharacters that could inject into a .env value.
13
+ * Escapes: $, backticks, \, and newlines — in addition to double quotes.
14
+ */
15
+ function escapeEnvValue(value) {
16
+ return value
17
+ .replace(/\\/g, "\\\\") // escape backslashes first
18
+ .replace(/\n/g, "\\n") // escape newlines
19
+ .replace(/\r/g, "\\r") // escape carriage returns
20
+ .replace(/`/g, "\\`") // escape backticks
21
+ .replace(/\$/g, "\\$") // escape dollar signs
22
+ .replace(/"/g, '\\"'); // escape double quotes
23
+ }
24
+ /**
25
+ * Parse a .env line, returning { key, value } or null if not a valid assignment.
26
+ * Uses a string-based split instead of regex to avoid injection from .env content.
27
+ */
28
+ function parseEnvLine(line) {
29
+ // Skip empty lines and comments
30
+ const trimmed = line.trim();
31
+ if (!trimmed || trimmed.startsWith("#"))
32
+ return null;
33
+ const eqIndex = trimmed.indexOf("=");
34
+ if (eqIndex === -1)
35
+ return null;
36
+ const key = trimmed.slice(0, eqIndex).trim();
37
+ // Value is everything after the first '=' (unquoted, no regex)
38
+ const rawValue = trimmed.slice(eqIndex + 1);
39
+ return { key, value: rawValue };
40
+ }
9
41
  /**
10
42
  * Upsert key=value pairs into a .env file.
11
43
  * - Existing keys are replaced.
12
44
  * - New keys are appended.
45
+ * - Keys are matched via string split (not regex) to prevent injection.
13
46
  */
14
47
  export function writeEnvFile(filename, vars, cwd = process.cwd()) {
15
48
  const filePath = path.resolve(cwd, filename);
16
- let lines = [];
49
+ const lines = [];
17
50
  if (fs.existsSync(filePath)) {
18
- lines = fs.readFileSync(filePath, "utf8").split("\n");
51
+ const content = fs.readFileSync(filePath, "utf8");
52
+ for (const line of content.split("\n")) {
53
+ const parsed = parseEnvLine(line);
54
+ // Remove lines that match any key we're about to upsert
55
+ if (parsed && vars.some((v) => v.key === parsed.key))
56
+ continue;
57
+ lines.push(line);
58
+ }
19
59
  }
20
60
  for (const { key, value } of vars) {
21
- const escapedValue = value.replace(/"/g, '\\"');
22
- const newLine = `${key}="${escapedValue}"`;
23
- const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
24
- const regex = new RegExp(`^${escapedKey}\\s*=.+$`, "m");
25
- const idx = lines.findIndex((l) => regex.test(l));
26
- if (idx !== -1) {
27
- lines[idx] = newLine;
28
- }
29
- else {
30
- lines.push(newLine);
31
- }
61
+ const escapedValue = escapeEnvValue(value);
62
+ lines.push(`${key}="${escapedValue}"`);
32
63
  }
33
64
  fs.writeFileSync(filePath, lines.join("\n"), "utf8");
34
65
  }
package/dist/index.d.ts CHANGED
@@ -14,8 +14,8 @@ export declare function loadCliEnv(): CliEnv;
14
14
  /**
15
15
  * Resolve MUNIN_PROJECT from multiple sources, in priority order:
16
16
  * 1. Explicit environment variable
17
- * 2. .env.local in current working directory
18
- * 3. .env in current working directory
17
+ * 2. .env.local in any ancestor directory (walk-up from CWD)
18
+ * 3. .env in any ancestor directory (walk-up from CWD)
19
19
  */
20
20
  export declare function resolveProjectId(): string | undefined;
21
21
  export declare function executeWithRetry<T>(task: () => Promise<T>, retries: number, backoffMs: number): Promise<T>;
package/dist/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  const REDACT_KEYS = ["apiKey", "authorization", "token", "secret", "password"];
2
4
  export function parseCliArgs(argv, usage) {
3
5
  const [action, payloadRaw] = argv;
@@ -21,68 +23,86 @@ export function loadCliEnv() {
21
23
  return {
22
24
  baseUrl: process.env.MUNIN_BASE_URL || "https://munin.kalera.dev",
23
25
  apiKey: process.env.MUNIN_API_KEY,
24
- timeoutMs: Number(process.env.MUNIN_TIMEOUT_MS ?? 15000),
25
- retries: Number(process.env.MUNIN_RETRIES ?? 3),
26
- backoffMs: Number(process.env.MUNIN_BACKOFF_MS ?? 300),
26
+ timeoutMs: safeParseInt(process.env.MUNIN_TIMEOUT_MS, 15_000),
27
+ retries: safeParseInt(process.env.MUNIN_RETRIES, 3),
28
+ backoffMs: safeParseInt(process.env.MUNIN_BACKOFF_MS, 300),
27
29
  };
28
30
  }
31
+ function safeParseInt(envVal, defaultVal) {
32
+ if (envVal === undefined)
33
+ return defaultVal;
34
+ const parsed = Number(envVal);
35
+ return isNaN(parsed) ? defaultVal : parsed;
36
+ }
37
+ /**
38
+ * Walk up the directory tree from `startDir` (default CWD) toward root,
39
+ * searching each ancestor for `filename`. Returns the value of the first match.
40
+ * Stops at filesystem root or when a `.git` dir is found (project boundary).
41
+ */
42
+ function resolveEnvFileUpward(filename, startDir) {
43
+ let current = startDir ?? process.cwd();
44
+ let last = "";
45
+ while (current !== last) {
46
+ last = current;
47
+ const filePath = path.join(current, filename);
48
+ try {
49
+ if (fs.existsSync(filePath)) {
50
+ const content = fs.readFileSync(filePath, "utf8");
51
+ for (const line of content.split("\n")) {
52
+ const trimmed = line.trim();
53
+ if (!trimmed.startsWith("MUNIN_PROJECT="))
54
+ continue;
55
+ return trimmed.slice("MUNIN_PROJECT=".length).trim();
56
+ }
57
+ }
58
+ }
59
+ catch (error) {
60
+ if (error.code !== "ENOENT") {
61
+ console.error(`[munin-runtime] Failed to read ${filePath}:`, error);
62
+ }
63
+ }
64
+ // Stop at git root (project boundary)
65
+ if (fs.existsSync(path.join(current, ".git")))
66
+ break;
67
+ current = path.dirname(current);
68
+ }
69
+ return undefined;
70
+ }
29
71
  /**
30
72
  * Resolve MUNIN_PROJECT from multiple sources, in priority order:
31
73
  * 1. Explicit environment variable
32
- * 2. .env.local in current working directory
33
- * 3. .env in current working directory
74
+ * 2. .env.local in any ancestor directory (walk-up from CWD)
75
+ * 3. .env in any ancestor directory (walk-up from CWD)
34
76
  */
35
77
  export function resolveProjectId() {
36
78
  // 1. Explicit env var (highest priority)
37
79
  if (process.env.MUNIN_PROJECT) {
38
80
  return process.env.MUNIN_PROJECT;
39
81
  }
40
- // 2. .env.local in CWD
41
- const envLocal = resolveEnvFile(".env.local");
42
- if (envLocal)
43
- return envLocal;
44
- // 3. .env in CWD
45
- const envFile = resolveEnvFile(".env");
46
- if (envFile)
47
- return envFile;
48
- return undefined;
49
- }
50
- /**
51
- * Read a .env file and extract MUNIN_PROJECT value.
52
- * Returns undefined if file doesn't exist or value not found.
53
- */
54
- function resolveEnvFile(filename) {
55
- try {
56
- const path = `${process.cwd()}/${filename}`;
57
- const fs = require("fs");
58
- if (!fs.existsSync(path))
59
- return undefined;
60
- const content = fs.readFileSync(path, "utf8");
61
- const match = content.match(/^MUNIN_PROJECT\s*=\s*(.+)$/m);
62
- if (match) {
63
- return match[1].trim();
64
- }
65
- }
66
- catch {
67
- // Ignore errors — file might be unreadable
68
- }
82
+ // 2. Walk upward from CWD — find .env.local in any ancestor dir
83
+ const fromLocal = resolveEnvFileUpward(".env.local");
84
+ if (fromLocal)
85
+ return fromLocal;
86
+ // 3. Walk upward from CWD — find .env in any ancestor dir
87
+ const fromEnv = resolveEnvFileUpward(".env");
88
+ if (fromEnv)
89
+ return fromEnv;
69
90
  return undefined;
70
91
  }
71
92
  export async function executeWithRetry(task, retries, backoffMs) {
72
93
  let attempt = 0;
73
94
  let lastError;
74
- while (attempt <= retries) {
95
+ while (attempt < retries) {
75
96
  try {
76
97
  return await task();
77
98
  }
78
99
  catch (error) {
79
100
  lastError = error;
80
- if (attempt === retries) {
101
+ attempt += 1;
102
+ if (attempt >= retries)
81
103
  break;
82
- }
83
104
  const jitter = Math.floor(Math.random() * 100);
84
105
  await sleep(backoffMs * 2 ** attempt + jitter);
85
- attempt += 1;
86
106
  }
87
107
  }
88
108
  throw lastError;
@@ -1,6 +1,8 @@
1
1
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
2
  import { loadCliEnv } from "./index.js";
3
- export declare function createMcpServerInstance(env: ReturnType<typeof loadCliEnv>): Server<{
3
+ export declare function createMcpServerInstance(env: ReturnType<typeof loadCliEnv>, opts?: {
4
+ allowMissingApiKey?: boolean;
5
+ }): Server<{
4
6
  method: string;
5
7
  params?: {
6
8
  [x: string]: unknown;
@@ -3,10 +3,13 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
3
3
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
4
4
  import { MuninClient } from "@kalera/munin-sdk";
5
5
  import { loadCliEnv, resolveProjectId, resolveEncryptionKey, safeError } from "./index.js";
6
- export function createMcpServerInstance(env) {
6
+ export function createMcpServerInstance(env, opts) {
7
+ if (!env.apiKey && !opts?.allowMissingApiKey) {
8
+ throw new Error("MUNIN_API_KEY is required. Set it in your environment or .env file.");
9
+ }
7
10
  const client = new MuninClient({
8
11
  baseUrl: env.baseUrl,
9
- apiKey: env.apiKey || 'test-key',
12
+ apiKey: env.apiKey,
10
13
  timeoutMs: env.timeoutMs,
11
14
  });
12
15
  const server = new Server({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kalera/munin-runtime",
3
- "version": "1.2.5",
3
+ "version": "1.2.8",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -12,7 +12,7 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "@modelcontextprotocol/sdk": "^1.27.1",
15
- "@kalera/munin-sdk": "1.2.5"
15
+ "@kalera/munin-sdk": "1.2.8"
16
16
  },
17
17
  "scripts": {
18
18
  "build": "tsc -p tsconfig.json",
package/src/env.ts CHANGED
@@ -1,6 +1,17 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
 
4
+ // Re-export all shared utilities from index.ts (single source of truth)
5
+ export {
6
+ parseCliArgs,
7
+ loadCliEnv,
8
+ resolveProjectId,
9
+ executeWithRetry,
10
+ safeError,
11
+ } from "./index.js";
12
+
13
+ export type { ParsedCliArgs, CliEnv } from "./index.js";
14
+
4
15
  export interface EnvVar {
5
16
  key: string;
6
17
  value: string;
@@ -13,10 +24,44 @@ export function resolveEncryptionKey(): string | undefined {
13
24
  return process.env.MUNIN_ENCRYPTION_KEY;
14
25
  }
15
26
 
27
+ /**
28
+ * Escape shell/metacharacters that could inject into a .env value.
29
+ * Escapes: $, backticks, \, and newlines — in addition to double quotes.
30
+ */
31
+ function escapeEnvValue(value: string): string {
32
+ return value
33
+ .replace(/\\/g, "\\\\") // escape backslashes first
34
+ .replace(/\n/g, "\\n") // escape newlines
35
+ .replace(/\r/g, "\\r") // escape carriage returns
36
+ .replace(/`/g, "\\`") // escape backticks
37
+ .replace(/\$/g, "\\$") // escape dollar signs
38
+ .replace(/"/g, '\\"'); // escape double quotes
39
+ }
40
+
41
+ /**
42
+ * Parse a .env line, returning { key, value } or null if not a valid assignment.
43
+ * Uses a string-based split instead of regex to avoid injection from .env content.
44
+ */
45
+ function parseEnvLine(line: string): EnvVar | null {
46
+ // Skip empty lines and comments
47
+ const trimmed = line.trim();
48
+ if (!trimmed || trimmed.startsWith("#")) return null;
49
+
50
+ const eqIndex = trimmed.indexOf("=");
51
+ if (eqIndex === -1) return null;
52
+
53
+ const key = trimmed.slice(0, eqIndex).trim();
54
+ // Value is everything after the first '=' (unquoted, no regex)
55
+ const rawValue = trimmed.slice(eqIndex + 1);
56
+
57
+ return { key, value: rawValue };
58
+ }
59
+
16
60
  /**
17
61
  * Upsert key=value pairs into a .env file.
18
62
  * - Existing keys are replaced.
19
63
  * - New keys are appended.
64
+ * - Keys are matched via string split (not regex) to prevent injection.
20
65
  */
21
66
  export function writeEnvFile(
22
67
  filename: string,
@@ -24,24 +69,21 @@ export function writeEnvFile(
24
69
  cwd: string = process.cwd(),
25
70
  ): void {
26
71
  const filePath = path.resolve(cwd, filename);
27
- let lines: string[] = [];
72
+ const lines: string[] = [];
28
73
 
29
74
  if (fs.existsSync(filePath)) {
30
- lines = fs.readFileSync(filePath, "utf8").split("\n");
75
+ const content = fs.readFileSync(filePath, "utf8");
76
+ for (const line of content.split("\n")) {
77
+ const parsed = parseEnvLine(line);
78
+ // Remove lines that match any key we're about to upsert
79
+ if (parsed && vars.some((v) => v.key === parsed.key)) continue;
80
+ lines.push(line);
81
+ }
31
82
  }
32
83
 
33
84
  for (const { key, value } of vars) {
34
- const escapedValue = value.replace(/"/g, '\\"');
35
- const newLine = `${key}="${escapedValue}"`;
36
- const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
37
- const regex = new RegExp(`^${escapedKey}\\s*=.+$`, "m");
38
-
39
- const idx = lines.findIndex((l) => regex.test(l));
40
- if (idx !== -1) {
41
- lines[idx] = newLine;
42
- } else {
43
- lines.push(newLine);
44
- }
85
+ const escapedValue = escapeEnvValue(value);
86
+ lines.push(`${key}="${escapedValue}"`);
45
87
  }
46
88
 
47
89
  fs.writeFileSync(filePath, lines.join("\n"), "utf8");
package/src/index.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
1
4
  export interface ParsedCliArgs {
2
5
  action: string;
3
6
  payload: Record<string, unknown>;
@@ -37,17 +40,58 @@ export function loadCliEnv(): CliEnv {
37
40
  return {
38
41
  baseUrl: process.env.MUNIN_BASE_URL || "https://munin.kalera.dev",
39
42
  apiKey: process.env.MUNIN_API_KEY,
40
- timeoutMs: Number(process.env.MUNIN_TIMEOUT_MS ?? 15000),
41
- retries: Number(process.env.MUNIN_RETRIES ?? 3),
42
- backoffMs: Number(process.env.MUNIN_BACKOFF_MS ?? 300),
43
+ timeoutMs: safeParseInt(process.env.MUNIN_TIMEOUT_MS, 15_000),
44
+ retries: safeParseInt(process.env.MUNIN_RETRIES, 3),
45
+ backoffMs: safeParseInt(process.env.MUNIN_BACKOFF_MS, 300),
43
46
  };
44
47
  }
45
48
 
49
+ function safeParseInt(envVal: string | undefined, defaultVal: number): number {
50
+ if (envVal === undefined) return defaultVal;
51
+ const parsed = Number(envVal);
52
+ return isNaN(parsed) ? defaultVal : parsed;
53
+ }
54
+
55
+ /**
56
+ * Walk up the directory tree from `startDir` (default CWD) toward root,
57
+ * searching each ancestor for `filename`. Returns the value of the first match.
58
+ * Stops at filesystem root or when a `.git` dir is found (project boundary).
59
+ */
60
+ function resolveEnvFileUpward(filename: string, startDir?: string): string | undefined {
61
+ let current = startDir ?? process.cwd();
62
+ let last = "";
63
+
64
+ while (current !== last) {
65
+ last = current;
66
+ const filePath = path.join(current, filename);
67
+ try {
68
+ if (fs.existsSync(filePath)) {
69
+ const content = fs.readFileSync(filePath, "utf8");
70
+ for (const line of content.split("\n")) {
71
+ const trimmed = line.trim();
72
+ if (!trimmed.startsWith("MUNIN_PROJECT=")) continue;
73
+ return trimmed.slice("MUNIN_PROJECT=".length).trim();
74
+ }
75
+ }
76
+ } catch (error) {
77
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
78
+ console.error(`[munin-runtime] Failed to read ${filePath}:`, error);
79
+ }
80
+ }
81
+
82
+ // Stop at git root (project boundary)
83
+ if (fs.existsSync(path.join(current, ".git"))) break;
84
+
85
+ current = path.dirname(current);
86
+ }
87
+ return undefined;
88
+ }
89
+
46
90
  /**
47
91
  * Resolve MUNIN_PROJECT from multiple sources, in priority order:
48
92
  * 1. Explicit environment variable
49
- * 2. .env.local in current working directory
50
- * 3. .env in current working directory
93
+ * 2. .env.local in any ancestor directory (walk-up from CWD)
94
+ * 3. .env in any ancestor directory (walk-up from CWD)
51
95
  */
52
96
  export function resolveProjectId(): string | undefined {
53
97
  // 1. Explicit env var (highest priority)
@@ -55,38 +99,17 @@ export function resolveProjectId(): string | undefined {
55
99
  return process.env.MUNIN_PROJECT;
56
100
  }
57
101
 
58
- // 2. .env.local in CWD
59
- const envLocal = resolveEnvFile(".env.local");
60
- if (envLocal) return envLocal;
102
+ // 2. Walk upward from CWD — find .env.local in any ancestor dir
103
+ const fromLocal = resolveEnvFileUpward(".env.local");
104
+ if (fromLocal) return fromLocal;
61
105
 
62
- // 3. .env in CWD
63
- const envFile = resolveEnvFile(".env");
64
- if (envFile) return envFile;
106
+ // 3. Walk upward from CWD — find .env in any ancestor dir
107
+ const fromEnv = resolveEnvFileUpward(".env");
108
+ if (fromEnv) return fromEnv;
65
109
 
66
110
  return undefined;
67
111
  }
68
112
 
69
- /**
70
- * Read a .env file and extract MUNIN_PROJECT value.
71
- * Returns undefined if file doesn't exist or value not found.
72
- */
73
- function resolveEnvFile(filename: string): string | undefined {
74
- try {
75
- const path = `${process.cwd()}/${filename}`;
76
- const fs = require("fs") as typeof import("fs");
77
- if (!fs.existsSync(path)) return undefined;
78
-
79
- const content = fs.readFileSync(path, "utf8");
80
- const match = content.match(/^MUNIN_PROJECT\s*=\s*(.+)$/m);
81
- if (match) {
82
- return match[1].trim();
83
- }
84
- } catch {
85
- // Ignore errors — file might be unreadable
86
- }
87
- return undefined;
88
- }
89
-
90
113
  export async function executeWithRetry<T>(
91
114
  task: () => Promise<T>,
92
115
  retries: number,
@@ -95,17 +118,15 @@ export async function executeWithRetry<T>(
95
118
  let attempt = 0;
96
119
  let lastError: unknown;
97
120
 
98
- while (attempt <= retries) {
121
+ while (attempt < retries) {
99
122
  try {
100
123
  return await task();
101
124
  } catch (error) {
102
125
  lastError = error;
103
- if (attempt === retries) {
104
- break;
105
- }
126
+ attempt += 1;
127
+ if (attempt >= retries) break;
106
128
  const jitter = Math.floor(Math.random() * 100);
107
129
  await sleep(backoffMs * 2 ** attempt + jitter);
108
- attempt += 1;
109
130
  }
110
131
  }
111
132
 
@@ -133,5 +154,6 @@ function redactText(text: string): string {
133
154
  function sleep(ms: number): Promise<void> {
134
155
  return new Promise((resolve) => setTimeout(resolve, ms));
135
156
  }
157
+
136
158
  export * from "./mcp-server.js";
137
159
  export * from "./env.js";
package/src/mcp-server.ts CHANGED
@@ -7,10 +7,19 @@ import {
7
7
  import { MuninClient } from "@kalera/munin-sdk";
8
8
  import { loadCliEnv, resolveProjectId, resolveEncryptionKey, safeError } from "./index.js";
9
9
 
10
- export function createMcpServerInstance(env: ReturnType<typeof loadCliEnv>) {
10
+ export function createMcpServerInstance(
11
+ env: ReturnType<typeof loadCliEnv>,
12
+ opts?: { allowMissingApiKey?: boolean },
13
+ ) {
14
+ if (!env.apiKey && !opts?.allowMissingApiKey) {
15
+ throw new Error(
16
+ "MUNIN_API_KEY is required. Set it in your environment or .env file.",
17
+ );
18
+ }
19
+
11
20
  const client = new MuninClient({
12
21
  baseUrl: env.baseUrl,
13
- apiKey: env.apiKey || 'test-key',
22
+ apiKey: env.apiKey,
14
23
  timeoutMs: env.timeoutMs,
15
24
  });
16
25