@melaya/runner 1.1.38 → 1.1.40

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.
@@ -159,6 +159,17 @@ def _config_hash() -> str:
159
159
  ]
160
160
  if _browser_capable():
161
161
  fields.append("browser")
162
+ # Surgical tool pinning: PRESENCE of pinned tool names is part of the host
163
+ # boot config, exactly like browser capability above — a warm host that
164
+ # never registered a pinned tool active must reboot when a turn first
165
+ # requests one. Byte-for-byte identical to runnerNamespace.ts
166
+ # _assistantConfigHash: lowercased+sorted+joined, appended ONLY when
167
+ # non-empty so a turn with no pinned tools hashes exactly like a
168
+ # pre-pinning build (parity preserved).
169
+ raw_pinned = os.environ.get("MEL_ASSISTANT_PINNED_TOOLS", "") or ""
170
+ pinned_tools = ",".join(sorted(t.lower() for t in raw_pinned.split(",") if t.strip()))
171
+ if pinned_tools:
172
+ fields.append(pinned_tools)
162
173
  canon = "|".join(fields)
163
174
  return hashlib.sha256(canon.encode("utf-8")).hexdigest()[:32]
164
175
 
@@ -547,6 +558,13 @@ def _build_agent():
547
558
  # explodes context. Bounded to the selected services so the model can't reach
548
559
  # a connector the user didn't enable / has no creds for.
549
560
  connector_services = [s.strip().lower() for s in os.environ.get("MEL_ASSISTANT_CONNECTORS", "").split(",") if s.strip()]
561
+ # Surgical tool pinning (Melaya Marketing): exact connector tool NAMES the
562
+ # server already resolved creds for (e.g. gsc_search_analytics,
563
+ # gads_update_budget) — passed straight through, no lowercasing, since tool
564
+ # names are case-sensitive Python identifiers (unlike connector_services,
565
+ # which are lowercased service ids). Registered ACTIVE at boot below so the
566
+ # model calls them directly instead of search_tools/activate_tool.
567
+ pinned_tool_names = [t.strip() for t in os.environ.get("MEL_ASSISTANT_PINNED_TOOLS", "").split(",") if t.strip()]
550
568
  # Self-arm the write-approval gate whenever connectors are active, so it can't
551
569
  # be silently OFF on an older runner build that didn't set this env. Writes
552
570
  # then ALWAYS require the in-chat approval card (fail-safe).
@@ -587,10 +605,17 @@ def _build_agent():
587
605
  # management + every browser action is directly callable, no search/activate.
588
606
  if browser_enabled:
589
607
  _budget = max(_budget, 64)
608
+ # Surgical tool pinning: widen the budget so every pinned tool fits
609
+ # alongside the base active set (melaya_agent + phone/browser if
610
+ # enabled) — mirrors the phone_enabled/browser_enabled widening above,
611
+ # sized to the ACTUAL pinned count instead of a fixed guess.
612
+ if pinned_tool_names:
613
+ _budget = max(_budget, len(pinned_tool_names) + len(categories))
590
614
  toolkit = build_lazy_toolkit(
591
615
  active_categories=categories,
592
616
  include_categories=categories + core_categories + connector_services,
593
617
  budget=_budget,
618
+ pinned_names=pinned_tool_names or None,
594
619
  )
595
620
  except Exception as exc:
596
621
  _log(f"toolkit build failed (connectors={connector_services}, core): {exc}; retrying melaya_agent only")
@@ -689,22 +714,35 @@ def _build_agent():
689
714
  if browser_enabled else ""
690
715
  )
691
716
  connector_rule = (
692
- "- ACTIVE CONNECTORS for this turn: " + ", ".join(connector_services) + ". "
693
- "These are the ONLY external systems available RIGHT NOW. This overrides the "
694
- "conversation history: if earlier in this chat you used a DIFFERENT connector "
695
- "(another ERP/app the user has since DESELECTED), it is NO LONGER available - do "
696
- "NOT search for or call its tools, and do NOT reuse tool names or API verbs from "
697
- "that system (e.g. do not look for another ERP's model/method names here).\n"
698
- "- Their tools are not all loaded upfront: call search_tools(query=...) with PLAIN "
699
- "BUSINESS keywords (\"sales orders\", \"customers\", \"unpaid invoices\", \"headcount\") "
700
- "- never another system's internal API names - then activate_tool(name=...) ONCE, then "
701
- "call it. The results ARE the available tools: pick the closest match and USE it; do "
702
- "NOT keep re-searching for a tool from a different system. If two searches for the same "
703
- "need return the same kind of tool, STOP and activate it.\n"
704
- "- Aggregations (top-N, group-by, totals, a chart) usually have NO dedicated tool: "
705
- "activate the connector's GENERIC list/query tool, read the rows, and compute the "
706
- "aggregation yourself. Never invent data - read it from the connector.\n"
707
- if connector_services else ""
717
+ # Surgical tool pinning: these exact tool names are already ACTIVE (not
718
+ # deferred) telling the model to search_tools/activate_tool them first
719
+ # would be a wasted round-trip against a tool that's already callable.
720
+ (
721
+ "- These tools are loaded and directly callable: " + ", ".join(pinned_tool_names) + ". "
722
+ "Call them directly; do NOT call search_tools/activate_tool for any of them.\n"
723
+ "- Aggregations (top-N, group-by, totals, a chart) usually have NO dedicated tool: "
724
+ "call the connector's GENERIC list/query tool, read the rows, and compute the "
725
+ "aggregation yourself. Never invent data - read it from the connector.\n"
726
+ )
727
+ if pinned_tool_names else
728
+ (
729
+ "- ACTIVE CONNECTORS for this turn: " + ", ".join(connector_services) + ". "
730
+ "These are the ONLY external systems available RIGHT NOW. This overrides the "
731
+ "conversation history: if earlier in this chat you used a DIFFERENT connector "
732
+ "(another ERP/app the user has since DESELECTED), it is NO LONGER available - do "
733
+ "NOT search for or call its tools, and do NOT reuse tool names or API verbs from "
734
+ "that system (e.g. do not look for another ERP's model/method names here).\n"
735
+ "- Their tools are not all loaded upfront: call search_tools(query=...) with PLAIN "
736
+ "BUSINESS keywords (\"sales orders\", \"customers\", \"unpaid invoices\", \"headcount\") "
737
+ "- never another system's internal API names - then activate_tool(name=...) ONCE, then "
738
+ "call it. The results ARE the available tools: pick the closest match and USE it; do "
739
+ "NOT keep re-searching for a tool from a different system. If two searches for the same "
740
+ "need return the same kind of tool, STOP and activate it.\n"
741
+ "- Aggregations (top-N, group-by, totals, a chart) usually have NO dedicated tool: "
742
+ "activate the connector's GENERIC list/query tool, read the rows, and compute the "
743
+ "aggregation yourself. Never invent data - read it from the connector.\n"
744
+ if connector_services else ""
745
+ )
708
746
  )
709
747
  # Core primitives are ALWAYS in the lazy pool now, so the model must be told
710
748
  # they exist (they are not pinned/loaded upfront) — otherwise it concludes "no
@@ -1374,6 +1374,12 @@ export async function connect(opts) {
1374
1374
  // ids). The host seeds a lazy toolkit from these so ANY connector's tools
1375
1375
  // are reachable without exploding context.
1376
1376
  MEL_ASSISTANT_CONNECTORS: Array.isArray(payload.connectors) ? payload.connectors.join(",") : "",
1377
+ // Surgical tool pinning: exact connector tool NAMES (not just service ids)
1378
+ // the server already resolved creds for. The host registers these ACTIVE
1379
+ // at boot instead of dumping them into the deferred pool, eliminating the
1380
+ // search_tools -> activate_tool round-trip for known marketing tools.
1381
+ // Byte-for-byte source of truth for _config_hash's pinned-tools field.
1382
+ MEL_ASSISTANT_PINNED_TOOLS: payload.pinnedTools?.join(",") ?? "",
1377
1383
  // Gate write connector-tools behind the in-chat approval card (fail-safe).
1378
1384
  MEL_ASSISTANT_CONNECTOR_HITL: Array.isArray(payload.connectors) && payload.connectors.length ? "1" : "",
1379
1385
  // HITL autonomy mode ("safe" | "autonomous" | "payments_only"). The host
@@ -2198,6 +2204,108 @@ export async function connect(opts) {
2198
2204
  });
2199
2205
  }
2200
2206
  });
2207
+ // ── DB "Test connection via runner" ───────────────────────────────
2208
+ // The server hands us DB credentials (over the authenticated socket) so we can
2209
+ // probe a database that only THIS machine can reach — IP-allow-listed or in a
2210
+ // VPC the cloud can't see. We open one read-only connection and report back.
2211
+ // Credentials arrive via stdin (never argv, never disk) and are not persisted.
2212
+ socket.on("runner:db-test", async (payload, ack) => {
2213
+ const sid = payload?.sessionId;
2214
+ if (!sid)
2215
+ return;
2216
+ ack?.({ ok: true });
2217
+ const reply = (ok, message, error) => socket.emit("runner:db-test-result", { session_id: sid, ok, message, error });
2218
+ try {
2219
+ const { ensurePythonEnv } = await import("./pythonEnv.js");
2220
+ const { getLocalSharedVersion } = await import("./sharedVendor.js");
2221
+ // Reuse the venv already on disk — pass the ACTUAL installed shared version
2222
+ // (the venv marker is an exact match on `${version}::${depsHash}`, so a
2223
+ // sentinel like "latest" would force a needless full rebuild). If the
2224
+ // bundle isn't present yet, bail with a clear message rather than build.
2225
+ const localVersion = getLocalSharedVersion();
2226
+ if (!localVersion) {
2227
+ reply(false, undefined, "runner is still setting up its Python runtime — try again in a moment");
2228
+ return;
2229
+ }
2230
+ const env = await ensurePythonEnv(opts.pythonPath, localVersion, (m) => { if (opts.verbose)
2231
+ console.log(chalk.gray(` [db-test venv] ${m}`)); });
2232
+ if (!env.ok) {
2233
+ reply(false, undefined, `venv bootstrap failed: ${env.reason}`);
2234
+ return;
2235
+ }
2236
+ const { existsSync, copyFileSync, mkdirSync } = await import("fs");
2237
+ const workDir = join(tmpdir(), `melaya-db-test-${Date.now()}`);
2238
+ mkdirSync(workDir, { recursive: true });
2239
+ const candidates = [
2240
+ join(__dirname, "localDbProbe.py"),
2241
+ join(__dirname, "..", "localDbProbe.py"),
2242
+ ];
2243
+ let found = "";
2244
+ for (const c of candidates) {
2245
+ if (existsSync(c)) {
2246
+ found = c;
2247
+ break;
2248
+ }
2249
+ }
2250
+ if (!found) {
2251
+ reply(false, undefined, "localDbProbe.py not found — update @melaya/runner");
2252
+ return;
2253
+ }
2254
+ const staged = join(workDir, "localDbProbe.py");
2255
+ copyFileSync(found, staged);
2256
+ const certBundle = (await import("./pythonEnv.js")).getCertBundlePath();
2257
+ const sslEnv = certBundle
2258
+ ? { SSL_CERT_FILE: certBundle, REQUESTS_CA_BUNDLE: certBundle } : {};
2259
+ const proc = spawn(env.pythonPath, ["-u", staged], {
2260
+ env: { ...process.env, ...sslEnv },
2261
+ cwd: workDir,
2262
+ });
2263
+ let stdout = "";
2264
+ let stderr = "";
2265
+ let done = false;
2266
+ const finish = (ok, message, error) => {
2267
+ if (done)
2268
+ return;
2269
+ done = true;
2270
+ reply(ok, message, error);
2271
+ };
2272
+ // Hard wall-clock cap so a hung TCP connect can't wedge the session.
2273
+ const killTimer = setTimeout(() => {
2274
+ try {
2275
+ proc.kill();
2276
+ }
2277
+ catch { /* already gone */ }
2278
+ finish(false, undefined, "probe timed out after 30s (host unreachable from the runner?)");
2279
+ }, 30_000);
2280
+ proc.stdout.on("data", (d) => { stdout += d.toString("utf-8"); });
2281
+ proc.stderr.on("data", (d) => { stderr += d.toString("utf-8"); });
2282
+ proc.on("error", (err) => { clearTimeout(killTimer); finish(false, undefined, err?.message || String(err)); });
2283
+ proc.on("close", (code) => {
2284
+ clearTimeout(killTimer);
2285
+ const jsonLine = stdout.split(/\r?\n/).map(s => s.trim()).filter(Boolean).reverse()[0] || "";
2286
+ let parsed = null;
2287
+ try {
2288
+ parsed = JSON.parse(jsonLine);
2289
+ }
2290
+ catch { /* handled below */ }
2291
+ if (parsed && typeof parsed.ok === "boolean") {
2292
+ finish(parsed.ok, parsed.message, parsed.error);
2293
+ }
2294
+ else {
2295
+ finish(false, undefined, `probe exited ${code ?? "?"}${stderr ? `: ${stderr.slice(-300)}` : ""}`);
2296
+ }
2297
+ });
2298
+ // Hand the credentials to the probe over stdin — never argv/disk.
2299
+ try {
2300
+ proc.stdin.write(JSON.stringify({ service: payload.service, creds: payload.creds || {} }));
2301
+ proc.stdin.end();
2302
+ }
2303
+ catch { /* proc.on('error') handles it */ }
2304
+ }
2305
+ catch (e) {
2306
+ reply(false, undefined, e?.message || String(e));
2307
+ }
2308
+ });
2201
2309
  socket.on("linkedin:start-login", async (req) => {
2202
2310
  const sid = req?.session_id;
2203
2311
  if (!sid)
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ localDbProbe.py — one-shot, read-only database reachability probe run BY THE
4
+ RUNNER on behalf of the Melaya server.
5
+
6
+ Why it exists: some databases sit behind an IP allow-list or inside a VPC that
7
+ the Melaya cloud can never reach, but the user's own runner (on their LAN /
8
+ bastion) can. The server hands us the resolved credentials over the already-
9
+ authenticated Socket.IO channel; we open a single real connection from THIS
10
+ machine's network vantage point, run a trivial read-only probe, and print the
11
+ outcome. We never persist the credentials and never write to the database.
12
+
13
+ Contract:
14
+ stdin : one JSON object {"service": "...", "creds": { ... }}
15
+ stdout : one JSON object {"ok": bool, "message"|"error": "..."}
16
+ Exit code is always 0 (the result is the JSON on stdout); any crash is caught
17
+ and reported as {"ok": false, "error": ...}.
18
+ """
19
+ import sys
20
+ import json
21
+ import base64
22
+ import hashlib
23
+ import urllib.parse
24
+
25
+
26
+ def _out(ok, text):
27
+ key = "message" if ok else "error"
28
+ sys.stdout.write(json.dumps({"ok": bool(ok), key: str(text)[:300]}))
29
+ sys.stdout.flush()
30
+ sys.exit(0)
31
+
32
+
33
+ def _pick(creds, *keys):
34
+ for k in keys:
35
+ v = (creds.get(k) or "").strip()
36
+ if v:
37
+ return v
38
+ return ""
39
+
40
+
41
+ # ── PostgreSQL (asyncpg) ─────────────────────────────────────────────────────
42
+ def probe_postgres(creds):
43
+ dsn = _pick(creds, "dsn", "POSTGRES_DSN")
44
+ if not dsn:
45
+ _out(False, "Connection DSN required")
46
+ try:
47
+ import asyncio
48
+ import asyncpg # type: ignore
49
+ except ImportError:
50
+ _out(False, "asyncpg not installed on this runner (pip install asyncpg)")
51
+
52
+ async def _run():
53
+ conn = await asyncpg.connect(dsn, timeout=10)
54
+ try:
55
+ await conn.fetchval("SELECT 1")
56
+ ver = await conn.fetchval("SELECT version()")
57
+ finally:
58
+ await conn.close()
59
+ return ver or ""
60
+
61
+ try:
62
+ ver = asyncio.run(asyncio.wait_for(_run(), timeout=15))
63
+ short = str(ver).split(" on ")[0] if ver else ""
64
+ _out(True, f"Connected to PostgreSQL{(' — ' + short) if short else ''}")
65
+ except Exception as e: # noqa: BLE001
66
+ _out(False, f"PostgreSQL connection failed: {e}")
67
+
68
+
69
+ # ── MySQL / MariaDB (PyMySQL — pure-python, sync) ────────────────────────────
70
+ def probe_mysql(creds):
71
+ dsn = _pick(creds, "dsn", "MYSQL_DSN")
72
+ if not dsn:
73
+ _out(False, "Connection DSN required")
74
+ try:
75
+ import pymysql # type: ignore
76
+ except ImportError:
77
+ _out(False, "PyMySQL not installed on this runner (pip install PyMySQL)")
78
+ try:
79
+ p = urllib.parse.urlparse(dsn)
80
+ conn = pymysql.connect(
81
+ host=p.hostname,
82
+ port=p.port or 3306,
83
+ user=urllib.parse.unquote(p.username or ""),
84
+ password=urllib.parse.unquote(p.password or ""),
85
+ db=(p.path or "").lstrip("/") or None,
86
+ connect_timeout=10,
87
+ read_timeout=10,
88
+ )
89
+ try:
90
+ with conn.cursor() as cur:
91
+ cur.execute("SELECT VERSION()")
92
+ row = cur.fetchone()
93
+ ver = (row[0] if row else "") or ""
94
+ finally:
95
+ conn.close()
96
+ _out(True, f"Connected to MySQL{(' — ' + str(ver)) if ver else ''}")
97
+ except Exception as e: # noqa: BLE001
98
+ _out(False, f"MySQL connection failed: {e}")
99
+
100
+
101
+ # ── Snowflake (key-pair JWT, mirrors the server handler) ─────────────────────
102
+ def probe_snowflake(creds):
103
+ account = _pick(creds, "account", "SNOWFLAKE_ACCOUNT")
104
+ user = _pick(creds, "user", "SNOWFLAKE_USER")
105
+ pk = _pick(creds, "private_key", "SNOWFLAKE_PRIVATE_KEY")
106
+ if not account or not user or not pk:
107
+ _out(False, "Account + Username + Private Key required")
108
+ try:
109
+ from cryptography.hazmat.primitives import serialization # type: ignore
110
+ import requests # type: ignore
111
+ except ImportError:
112
+ _out(False, "cryptography/requests not installed on this runner")
113
+
114
+ def b64url(b):
115
+ if isinstance(b, str):
116
+ b = b.encode()
117
+ return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
118
+
119
+ try:
120
+ import time
121
+ key = serialization.load_pem_private_key(pk.encode(), password=None)
122
+ der_spki = key.public_key().public_bytes(
123
+ serialization.Encoding.DER,
124
+ serialization.PublicFormat.SubjectPublicKeyInfo,
125
+ )
126
+ fp = "SHA256:" + base64.b64encode(hashlib.sha256(der_spki).digest()).decode()
127
+ acct_upper = account.upper()
128
+ if "." in acct_upper:
129
+ acct_upper = acct_upper.split(".")[0]
130
+ qual = f"{acct_upper}.{user.upper()}"
131
+ now = int(time.time())
132
+ header = b64url(json.dumps({"alg": "RS256", "typ": "JWT"}))
133
+ payload = b64url(json.dumps(
134
+ {"iss": f"{qual}.{fp}", "sub": qual, "iat": now, "exp": now + 3540}))
135
+ signing_input = f"{header}.{payload}".encode()
136
+ from cryptography.hazmat.primitives import hashes # type: ignore
137
+ from cryptography.hazmat.primitives.asymmetric import padding # type: ignore
138
+ sig = key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256())
139
+ jwt = f"{header}.{payload}.{b64url(sig)}"
140
+ host = f"https://{account.replace('_', '-').lower()}.snowflakecomputing.com"
141
+ r = requests.post(
142
+ f"{host}/api/v2/statements",
143
+ headers={
144
+ "Authorization": f"Bearer {jwt}",
145
+ "X-Snowflake-Authorization-Token-Type": "KEYPAIR_JWT",
146
+ "Content-Type": "application/json",
147
+ "Accept": "application/json",
148
+ },
149
+ json={"statement": "SELECT CURRENT_VERSION()", "timeout": 20},
150
+ timeout=15,
151
+ )
152
+ if r.ok:
153
+ v = ""
154
+ try:
155
+ v = (r.json().get("data") or [[None]])[0][0] or ""
156
+ except Exception: # noqa: BLE001
157
+ pass
158
+ _out(True, f"Connected to Snowflake{(' (v' + str(v) + ')') if v else ''}")
159
+ _out(False, f"Snowflake auth/query failed: HTTP {r.status_code} {r.text[:160]}")
160
+ except Exception as e: # noqa: BLE001
161
+ _out(False, f"Snowflake connection failed: {e}")
162
+
163
+
164
+ # ── Databricks (PAT or OAuth M2M, mirrors the server handler) ────────────────
165
+ def probe_databricks(creds):
166
+ host = _pick(creds, "workspace_url", "DATABRICKS_HOST").rstrip("/")
167
+ pat = _pick(creds, "token", "DATABRICKS_TOKEN")
168
+ cid = _pick(creds, "client_id", "DATABRICKS_CLIENT_ID")
169
+ sec = _pick(creds, "client_secret", "DATABRICKS_CLIENT_SECRET")
170
+ if not host:
171
+ _out(False, "Workspace URL required")
172
+ try:
173
+ import requests # type: ignore
174
+ except ImportError:
175
+ _out(False, "requests not installed on this runner")
176
+ try:
177
+ bearer = pat
178
+ if not bearer:
179
+ if not cid or not sec:
180
+ _out(False, "Provide a Personal Access Token, or Client ID + Client Secret")
181
+ tr = requests.post(
182
+ f"{host}/oidc/v1/token",
183
+ auth=(cid, sec),
184
+ data={"grant_type": "client_credentials", "scope": "all-apis"},
185
+ timeout=12,
186
+ )
187
+ tj = {}
188
+ try:
189
+ tj = tr.json()
190
+ except Exception: # noqa: BLE001
191
+ pass
192
+ if not tr.ok or not tj.get("access_token"):
193
+ _out(False, f"Databricks OAuth failed: {tj.get('error_description') or tj.get('error') or ('HTTP ' + str(tr.status_code))}")
194
+ bearer = tj["access_token"]
195
+ r = requests.get(
196
+ f"{host}/api/2.1/clusters/list?page_size=1",
197
+ headers={"Authorization": f"Bearer {bearer}"},
198
+ timeout=12,
199
+ )
200
+ if r.ok:
201
+ _out(True, f"Connected to Databricks ({host.replace('https://', '').replace('http://', '')})")
202
+ _out(False, f"Databricks unreachable: HTTP {r.status_code} {r.text[:120]}")
203
+ except Exception as e: # noqa: BLE001
204
+ _out(False, f"Databricks connection failed: {e}")
205
+
206
+
207
+ # ── SQLite (local file on the runner) ────────────────────────────────────────
208
+ def probe_sqlite(creds):
209
+ import os
210
+ import sqlite3
211
+ path = _pick(creds, "dsn", "db_path", "path")
212
+ if not path:
213
+ _out(False, "Database file path required")
214
+ if not os.path.exists(path):
215
+ _out(False, f"No SQLite file at {path}")
216
+ try:
217
+ conn = sqlite3.connect(path, timeout=8)
218
+ try:
219
+ conn.execute("SELECT 1")
220
+ finally:
221
+ conn.close()
222
+ _out(True, f"Opened SQLite database ({os.path.basename(path)})")
223
+ except Exception as e: # noqa: BLE001
224
+ _out(False, f"SQLite open failed: {e}")
225
+
226
+
227
+ _PROBES = {
228
+ "postgres": probe_postgres,
229
+ "mysql": probe_mysql,
230
+ "snowflake": probe_snowflake,
231
+ "databricks": probe_databricks,
232
+ "sqlite": probe_sqlite,
233
+ }
234
+
235
+
236
+ def main():
237
+ try:
238
+ job = json.loads(sys.stdin.read() or "{}")
239
+ except Exception as e: # noqa: BLE001
240
+ _out(False, f"bad probe job: {e}")
241
+ return
242
+ service = (job.get("service") or "").strip()
243
+ creds = job.get("creds") or {}
244
+ fn = _PROBES.get(service)
245
+ if not fn:
246
+ _out(False, f"unsupported service: {service}")
247
+ return
248
+ fn(creds)
249
+
250
+
251
+ if __name__ == "__main__":
252
+ main()
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ localDbProbe.py — one-shot, read-only database reachability probe run BY THE
4
+ RUNNER on behalf of the Melaya server.
5
+
6
+ Why it exists: some databases sit behind an IP allow-list or inside a VPC that
7
+ the Melaya cloud can never reach, but the user's own runner (on their LAN /
8
+ bastion) can. The server hands us the resolved credentials over the already-
9
+ authenticated Socket.IO channel; we open a single real connection from THIS
10
+ machine's network vantage point, run a trivial read-only probe, and print the
11
+ outcome. We never persist the credentials and never write to the database.
12
+
13
+ Contract:
14
+ stdin : one JSON object {"service": "...", "creds": { ... }}
15
+ stdout : one JSON object {"ok": bool, "message"|"error": "..."}
16
+ Exit code is always 0 (the result is the JSON on stdout); any crash is caught
17
+ and reported as {"ok": false, "error": ...}.
18
+ """
19
+ import sys
20
+ import json
21
+ import base64
22
+ import hashlib
23
+ import urllib.parse
24
+
25
+
26
+ def _out(ok, text):
27
+ key = "message" if ok else "error"
28
+ sys.stdout.write(json.dumps({"ok": bool(ok), key: str(text)[:300]}))
29
+ sys.stdout.flush()
30
+ sys.exit(0)
31
+
32
+
33
+ def _pick(creds, *keys):
34
+ for k in keys:
35
+ v = (creds.get(k) or "").strip()
36
+ if v:
37
+ return v
38
+ return ""
39
+
40
+
41
+ # ── PostgreSQL (asyncpg) ─────────────────────────────────────────────────────
42
+ def probe_postgres(creds):
43
+ dsn = _pick(creds, "dsn", "POSTGRES_DSN")
44
+ if not dsn:
45
+ _out(False, "Connection DSN required")
46
+ try:
47
+ import asyncio
48
+ import asyncpg # type: ignore
49
+ except ImportError:
50
+ _out(False, "asyncpg not installed on this runner (pip install asyncpg)")
51
+
52
+ async def _run():
53
+ conn = await asyncpg.connect(dsn, timeout=10)
54
+ try:
55
+ await conn.fetchval("SELECT 1")
56
+ ver = await conn.fetchval("SELECT version()")
57
+ finally:
58
+ await conn.close()
59
+ return ver or ""
60
+
61
+ try:
62
+ ver = asyncio.run(asyncio.wait_for(_run(), timeout=15))
63
+ short = str(ver).split(" on ")[0] if ver else ""
64
+ _out(True, f"Connected to PostgreSQL{(' — ' + short) if short else ''}")
65
+ except Exception as e: # noqa: BLE001
66
+ _out(False, f"PostgreSQL connection failed: {e}")
67
+
68
+
69
+ # ── MySQL / MariaDB (PyMySQL — pure-python, sync) ────────────────────────────
70
+ def probe_mysql(creds):
71
+ dsn = _pick(creds, "dsn", "MYSQL_DSN")
72
+ if not dsn:
73
+ _out(False, "Connection DSN required")
74
+ try:
75
+ import pymysql # type: ignore
76
+ except ImportError:
77
+ _out(False, "PyMySQL not installed on this runner (pip install PyMySQL)")
78
+ try:
79
+ p = urllib.parse.urlparse(dsn)
80
+ conn = pymysql.connect(
81
+ host=p.hostname,
82
+ port=p.port or 3306,
83
+ user=urllib.parse.unquote(p.username or ""),
84
+ password=urllib.parse.unquote(p.password or ""),
85
+ db=(p.path or "").lstrip("/") or None,
86
+ connect_timeout=10,
87
+ read_timeout=10,
88
+ )
89
+ try:
90
+ with conn.cursor() as cur:
91
+ cur.execute("SELECT VERSION()")
92
+ row = cur.fetchone()
93
+ ver = (row[0] if row else "") or ""
94
+ finally:
95
+ conn.close()
96
+ _out(True, f"Connected to MySQL{(' — ' + str(ver)) if ver else ''}")
97
+ except Exception as e: # noqa: BLE001
98
+ _out(False, f"MySQL connection failed: {e}")
99
+
100
+
101
+ # ── Snowflake (key-pair JWT, mirrors the server handler) ─────────────────────
102
+ def probe_snowflake(creds):
103
+ account = _pick(creds, "account", "SNOWFLAKE_ACCOUNT")
104
+ user = _pick(creds, "user", "SNOWFLAKE_USER")
105
+ pk = _pick(creds, "private_key", "SNOWFLAKE_PRIVATE_KEY")
106
+ if not account or not user or not pk:
107
+ _out(False, "Account + Username + Private Key required")
108
+ try:
109
+ from cryptography.hazmat.primitives import serialization # type: ignore
110
+ import requests # type: ignore
111
+ except ImportError:
112
+ _out(False, "cryptography/requests not installed on this runner")
113
+
114
+ def b64url(b):
115
+ if isinstance(b, str):
116
+ b = b.encode()
117
+ return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
118
+
119
+ try:
120
+ import time
121
+ key = serialization.load_pem_private_key(pk.encode(), password=None)
122
+ der_spki = key.public_key().public_bytes(
123
+ serialization.Encoding.DER,
124
+ serialization.PublicFormat.SubjectPublicKeyInfo,
125
+ )
126
+ fp = "SHA256:" + base64.b64encode(hashlib.sha256(der_spki).digest()).decode()
127
+ acct_upper = account.upper()
128
+ if "." in acct_upper:
129
+ acct_upper = acct_upper.split(".")[0]
130
+ qual = f"{acct_upper}.{user.upper()}"
131
+ now = int(time.time())
132
+ header = b64url(json.dumps({"alg": "RS256", "typ": "JWT"}))
133
+ payload = b64url(json.dumps(
134
+ {"iss": f"{qual}.{fp}", "sub": qual, "iat": now, "exp": now + 3540}))
135
+ signing_input = f"{header}.{payload}".encode()
136
+ from cryptography.hazmat.primitives import hashes # type: ignore
137
+ from cryptography.hazmat.primitives.asymmetric import padding # type: ignore
138
+ sig = key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256())
139
+ jwt = f"{header}.{payload}.{b64url(sig)}"
140
+ host = f"https://{account.replace('_', '-').lower()}.snowflakecomputing.com"
141
+ r = requests.post(
142
+ f"{host}/api/v2/statements",
143
+ headers={
144
+ "Authorization": f"Bearer {jwt}",
145
+ "X-Snowflake-Authorization-Token-Type": "KEYPAIR_JWT",
146
+ "Content-Type": "application/json",
147
+ "Accept": "application/json",
148
+ },
149
+ json={"statement": "SELECT CURRENT_VERSION()", "timeout": 20},
150
+ timeout=15,
151
+ )
152
+ if r.ok:
153
+ v = ""
154
+ try:
155
+ v = (r.json().get("data") or [[None]])[0][0] or ""
156
+ except Exception: # noqa: BLE001
157
+ pass
158
+ _out(True, f"Connected to Snowflake{(' (v' + str(v) + ')') if v else ''}")
159
+ _out(False, f"Snowflake auth/query failed: HTTP {r.status_code} {r.text[:160]}")
160
+ except Exception as e: # noqa: BLE001
161
+ _out(False, f"Snowflake connection failed: {e}")
162
+
163
+
164
+ # ── Databricks (PAT or OAuth M2M, mirrors the server handler) ────────────────
165
+ def probe_databricks(creds):
166
+ host = _pick(creds, "workspace_url", "DATABRICKS_HOST").rstrip("/")
167
+ pat = _pick(creds, "token", "DATABRICKS_TOKEN")
168
+ cid = _pick(creds, "client_id", "DATABRICKS_CLIENT_ID")
169
+ sec = _pick(creds, "client_secret", "DATABRICKS_CLIENT_SECRET")
170
+ if not host:
171
+ _out(False, "Workspace URL required")
172
+ try:
173
+ import requests # type: ignore
174
+ except ImportError:
175
+ _out(False, "requests not installed on this runner")
176
+ try:
177
+ bearer = pat
178
+ if not bearer:
179
+ if not cid or not sec:
180
+ _out(False, "Provide a Personal Access Token, or Client ID + Client Secret")
181
+ tr = requests.post(
182
+ f"{host}/oidc/v1/token",
183
+ auth=(cid, sec),
184
+ data={"grant_type": "client_credentials", "scope": "all-apis"},
185
+ timeout=12,
186
+ )
187
+ tj = {}
188
+ try:
189
+ tj = tr.json()
190
+ except Exception: # noqa: BLE001
191
+ pass
192
+ if not tr.ok or not tj.get("access_token"):
193
+ _out(False, f"Databricks OAuth failed: {tj.get('error_description') or tj.get('error') or ('HTTP ' + str(tr.status_code))}")
194
+ bearer = tj["access_token"]
195
+ r = requests.get(
196
+ f"{host}/api/2.1/clusters/list?page_size=1",
197
+ headers={"Authorization": f"Bearer {bearer}"},
198
+ timeout=12,
199
+ )
200
+ if r.ok:
201
+ _out(True, f"Connected to Databricks ({host.replace('https://', '').replace('http://', '')})")
202
+ _out(False, f"Databricks unreachable: HTTP {r.status_code} {r.text[:120]}")
203
+ except Exception as e: # noqa: BLE001
204
+ _out(False, f"Databricks connection failed: {e}")
205
+
206
+
207
+ # ── SQLite (local file on the runner) ────────────────────────────────────────
208
+ def probe_sqlite(creds):
209
+ import os
210
+ import sqlite3
211
+ path = _pick(creds, "dsn", "db_path", "path")
212
+ if not path:
213
+ _out(False, "Database file path required")
214
+ if not os.path.exists(path):
215
+ _out(False, f"No SQLite file at {path}")
216
+ try:
217
+ conn = sqlite3.connect(path, timeout=8)
218
+ try:
219
+ conn.execute("SELECT 1")
220
+ finally:
221
+ conn.close()
222
+ _out(True, f"Opened SQLite database ({os.path.basename(path)})")
223
+ except Exception as e: # noqa: BLE001
224
+ _out(False, f"SQLite open failed: {e}")
225
+
226
+
227
+ _PROBES = {
228
+ "postgres": probe_postgres,
229
+ "mysql": probe_mysql,
230
+ "snowflake": probe_snowflake,
231
+ "databricks": probe_databricks,
232
+ "sqlite": probe_sqlite,
233
+ }
234
+
235
+
236
+ def main():
237
+ try:
238
+ job = json.loads(sys.stdin.read() or "{}")
239
+ except Exception as e: # noqa: BLE001
240
+ _out(False, f"bad probe job: {e}")
241
+ return
242
+ service = (job.get("service") or "").strip()
243
+ creds = job.get("creds") or {}
244
+ fn = _PROBES.get(service)
245
+ if not fn:
246
+ _out(False, f"unsupported service: {service}")
247
+ return
248
+ fn(creds)
249
+
250
+
251
+ if __name__ == "__main__":
252
+ main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.1.38",
3
+ "version": "1.1.40",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -15,11 +15,12 @@
15
15
  "dist/**/*.py",
16
16
  "localRagIngest.py",
17
17
  "localRagRetrieve.py",
18
+ "localDbProbe.py",
18
19
  "nltk_data/**",
19
20
  "README.md"
20
21
  ],
21
22
  "scripts": {
22
- "build": "tsc && node -e \"const fs=require('fs'); fs.copyFileSync('localRagIngest.py','dist/localRagIngest.py'); fs.copyFileSync('localRagRetrieve.py','dist/localRagRetrieve.py'); fs.copyFileSync('src/assistantHost.py','dist/assistantHost.py')\"",
23
+ "build": "tsc && node -e \"const fs=require('fs'); fs.copyFileSync('localRagIngest.py','dist/localRagIngest.py'); fs.copyFileSync('localRagRetrieve.py','dist/localRagRetrieve.py'); fs.copyFileSync('localDbProbe.py','dist/localDbProbe.py'); fs.copyFileSync('src/assistantHost.py','dist/assistantHost.py')\"",
23
24
  "test": "node --test --import tsx \"src/**/*.test.ts\"",
24
25
  "prepublishOnly": "npm run build"
25
26
  },