@melaya/runner 1.1.38 → 1.1.41

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
 
@@ -512,9 +523,26 @@ def _extract_text(result) -> str:
512
523
  def _build_agent():
513
524
  """Build the single long-lived ReActAgent for this session."""
514
525
  from shared.runtime.agent_factory import make_agent
526
+ try:
527
+ from shared.orchestration.delegation import make_delegating_agent, DelegateSpec
528
+ _HAS_DELEGATION = True
529
+ except Exception:
530
+ # A runner on the NEW assistantHost.py against a STALE shared bundle that
531
+ # predates delegation.py must NOT brick — degrade to the plain agent
532
+ # build (delegates ignored, model_provider mapped back to provider=).
533
+ _HAS_DELEGATION = False
534
+ DelegateSpec = None # sentinel; only referenced when _HAS_DELEGATION is True
535
+
536
+ def make_delegating_agent(*, model_provider="", delegates=None, **_kw): # type: ignore[misc]
537
+ return make_agent(provider=model_provider, **_kw)
515
538
  from shared.runtime.registry import build_toolkit
516
539
 
517
540
  provider = os.environ.get("MEL_ASSISTANT_PROVIDER", "claude_code")
541
+ # Weak local models orchestrate sub-agents poorly — gate delegation OFF for
542
+ # them (mirrors the cloud SUBAGENT_INCAPABLE_PROVIDERS). When off, delegates
543
+ # is None and make_delegating_agent is byte-identical to the old make_agent
544
+ # build (no delegate_task tool, no prompt note).
545
+ _delegate_capable = _HAS_DELEGATION and provider.strip().lower() not in ("ollama", "lmstudio")
518
546
  model = os.environ.get("MEL_ASSISTANT_MODEL", "") or None
519
547
  language = os.environ.get("MEL_ASSISTANT_LANGUAGE", "en")
520
548
 
@@ -547,6 +575,13 @@ def _build_agent():
547
575
  # explodes context. Bounded to the selected services so the model can't reach
548
576
  # a connector the user didn't enable / has no creds for.
549
577
  connector_services = [s.strip().lower() for s in os.environ.get("MEL_ASSISTANT_CONNECTORS", "").split(",") if s.strip()]
578
+ # Surgical tool pinning (Melaya Marketing): exact connector tool NAMES the
579
+ # server already resolved creds for (e.g. gsc_search_analytics,
580
+ # gads_update_budget) — passed straight through, no lowercasing, since tool
581
+ # names are case-sensitive Python identifiers (unlike connector_services,
582
+ # which are lowercased service ids). Registered ACTIVE at boot below so the
583
+ # model calls them directly instead of search_tools/activate_tool.
584
+ pinned_tool_names = [t.strip() for t in os.environ.get("MEL_ASSISTANT_PINNED_TOOLS", "").split(",") if t.strip()]
550
585
  # Self-arm the write-approval gate whenever connectors are active, so it can't
551
586
  # be silently OFF on an older runner build that didn't set this env. Writes
552
587
  # then ALWAYS require the in-chat approval card (fail-safe).
@@ -587,10 +622,17 @@ def _build_agent():
587
622
  # management + every browser action is directly callable, no search/activate.
588
623
  if browser_enabled:
589
624
  _budget = max(_budget, 64)
625
+ # Surgical tool pinning: widen the budget so every pinned tool fits
626
+ # alongside the base active set (melaya_agent + phone/browser if
627
+ # enabled) — mirrors the phone_enabled/browser_enabled widening above,
628
+ # sized to the ACTUAL pinned count instead of a fixed guess.
629
+ if pinned_tool_names:
630
+ _budget = max(_budget, len(pinned_tool_names) + len(categories))
590
631
  toolkit = build_lazy_toolkit(
591
632
  active_categories=categories,
592
633
  include_categories=categories + core_categories + connector_services,
593
634
  budget=_budget,
635
+ pinned_names=pinned_tool_names or None,
594
636
  )
595
637
  except Exception as exc:
596
638
  _log(f"toolkit build failed (connectors={connector_services}, core): {exc}; retrying melaya_agent only")
@@ -689,22 +731,35 @@ def _build_agent():
689
731
  if browser_enabled else ""
690
732
  )
691
733
  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 ""
734
+ # Surgical tool pinning: these exact tool names are already ACTIVE (not
735
+ # deferred) telling the model to search_tools/activate_tool them first
736
+ # would be a wasted round-trip against a tool that's already callable.
737
+ (
738
+ "- These tools are loaded and directly callable: " + ", ".join(pinned_tool_names) + ". "
739
+ "Call them directly; do NOT call search_tools/activate_tool for any of them.\n"
740
+ "- Aggregations (top-N, group-by, totals, a chart) usually have NO dedicated tool: "
741
+ "call the connector's GENERIC list/query tool, read the rows, and compute the "
742
+ "aggregation yourself. Never invent data - read it from the connector.\n"
743
+ )
744
+ if pinned_tool_names else
745
+ (
746
+ "- ACTIVE CONNECTORS for this turn: " + ", ".join(connector_services) + ". "
747
+ "These are the ONLY external systems available RIGHT NOW. This overrides the "
748
+ "conversation history: if earlier in this chat you used a DIFFERENT connector "
749
+ "(another ERP/app the user has since DESELECTED), it is NO LONGER available - do "
750
+ "NOT search for or call its tools, and do NOT reuse tool names or API verbs from "
751
+ "that system (e.g. do not look for another ERP's model/method names here).\n"
752
+ "- Their tools are not all loaded upfront: call search_tools(query=...) with PLAIN "
753
+ "BUSINESS keywords (\"sales orders\", \"customers\", \"unpaid invoices\", \"headcount\") "
754
+ "- never another system's internal API names - then activate_tool(name=...) ONCE, then "
755
+ "call it. The results ARE the available tools: pick the closest match and USE it; do "
756
+ "NOT keep re-searching for a tool from a different system. If two searches for the same "
757
+ "need return the same kind of tool, STOP and activate it.\n"
758
+ "- Aggregations (top-N, group-by, totals, a chart) usually have NO dedicated tool: "
759
+ "activate the connector's GENERIC list/query tool, read the rows, and compute the "
760
+ "aggregation yourself. Never invent data - read it from the connector.\n"
761
+ if connector_services else ""
762
+ )
708
763
  )
709
764
  # Core primitives are ALWAYS in the lazy pool now, so the model must be told
710
765
  # they exist (they are not pinned/loaded upfront) — otherwise it concludes "no
@@ -736,9 +791,22 @@ def _build_agent():
736
791
  "- CHARTS: when you use the `chart` tool, the graph ONLY renders if you paste the EXACT ```chart ...``` fenced block it returns into your reply. ALWAYS output that block verbatim where you want the chart shown; NEVER say 'the chart is above/below' or describe it instead. If you made several charts, include each block; if you regenerated one, include ONLY the final good version.\n"
737
792
  "- If a question is outside the platform, answer normally without tools.\n"
738
793
  + (f"- Answer in the user's language: {language}.\n" if language and language != "en" else "")
794
+ # Provider-agnostic sub-agent delegation. The delegate_task tool is
795
+ # registered by make_delegating_agent below (only when _delegate_capable);
796
+ # tell the model it exists and how to use it well. Sub-agents share this
797
+ # same toolkit (writes hit the SAME HITL gate) and cannot delegate further.
798
+ # Gated on _delegate_capable so a weak-model turn's prompt is unchanged.
799
+ + ((
800
+ "- You MAY call delegate_task(\"Specialist\", \"<scoped sub-task>\") to hand a "
801
+ "self-contained sub-analysis to a specialist sub-agent. Use it to fan out "
802
+ "INDEPENDENT sub-tasks (they can run in parallel and, when useful, on a "
803
+ "different model), then SYNTHESISE the results yourself. Do not over-delegate, "
804
+ "never delegate the whole request, and remember sub-agents cannot delegate "
805
+ "further.\n"
806
+ ) if _delegate_capable else "")
739
807
  )
740
808
 
741
- agent = make_agent(
809
+ agent = make_delegating_agent(
742
810
  # This name IS the agent identity that agentscope stamps onto the traced
743
811
  # `invoke_agent <name>` span + gen_ai.agent.name — which is EXACTLY the
744
812
  # dimension the Overview "by agent" token breakdown groups on
@@ -749,7 +817,28 @@ def _build_agent():
749
817
  sys_prompt=sys_prompt,
750
818
  toolkit=toolkit,
751
819
  model_name=model,
752
- provider=provider,
820
+ # make_delegating_agent forwards model_provider both to make_agent (as the
821
+ # provider alias -> identical resolution to the old provider=) AND to the
822
+ # delegate factory, so a specialist defaults to the SAME provider as the
823
+ # lead. Pass provider here as model_provider (not provider=) so the sub-
824
+ # agent inherits it instead of falling back to the anthropic env default.
825
+ model_provider=provider,
826
+ # Provider-agnostic sub-agent delegation (UNCONDITIONAL — always on). The
827
+ # ONLY behavioural change vs the previous make_agent build is that the
828
+ # agent now also carries a delegate_task tool; every other kwarg below is
829
+ # unchanged. The Specialist shares THIS toolkit (HITL gating preserved),
830
+ # is built via make_agent (no delegate_task -> depth capped at 1), and can
831
+ # be run on a different model via MEL_ASSISTANT_DELEGATE_MODEL/PROVIDER.
832
+ delegates=([
833
+ DelegateSpec(
834
+ name="Specialist",
835
+ crew="assistant_delegate",
836
+ factory="make_assistant_specialist",
837
+ model_name=os.environ.get("MEL_ASSISTANT_DELEGATE_MODEL", "") or "",
838
+ model_provider=os.environ.get("MEL_ASSISTANT_DELEGATE_PROVIDER", "") or "",
839
+ max_iters=6,
840
+ ),
841
+ ] if _delegate_capable else None),
753
842
  # Cloud providers on the runner path (browser control turns pinned to the
754
843
  # runner even for a cloud model) get their key passed down from the server
755
844
  # as MEL_ASSISTANT_PROVIDER_API_KEY. Empty for local providers (claude_code
@@ -835,6 +924,14 @@ def _register_stream_hooks(agent) -> None:
835
924
  # the post-hook's trailing `output` arg so ONE shape works for both.
836
925
  def _pre_print_hook(_self, kw, *_rest) -> None:
837
926
  try:
927
+ # Sub-agents (Specialist) share this CLASS hook, but only the LEAD
928
+ # ("Assistant") streams to the client. A delegate's deltas / tool
929
+ # chips must NOT leak into the lead's answer bubble — its result comes
930
+ # back via the delegate_task tool result. (Specialist name is
931
+ # "Assistant:Specialist", so an exact-match on the lead name filters
932
+ # every sub-agent.)
933
+ if getattr(_self, "name", "") != "Assistant":
934
+ return
838
935
  tid = _stream.get("turnId")
839
936
  if not tid or not isinstance(kw, dict):
840
937
  return
@@ -873,6 +970,10 @@ def _register_stream_hooks(agent) -> None:
873
970
 
874
971
  def _post_acting_hook(_self, kw, *_rest) -> None:
875
972
  try:
973
+ # Only the LEAD streams tool chips; a delegate's tool calls must not
974
+ # surface in the lead's turn (see _pre_print_hook).
975
+ if getattr(_self, "name", "") != "Assistant":
976
+ return
876
977
  tid = _stream.get("turnId")
877
978
  if not tid or not isinstance(kw, dict):
878
979
  return
@@ -955,6 +1056,15 @@ def _run_turn(agent, turn_id: str, message: str, browser_turn: bool = False, ima
955
1056
  _stream["cancel"] = False # fresh turn — clear any stale STOP
956
1057
  _stream["usedBrowser"] = False
957
1058
 
1059
+ # Reset the per-turn sub-agent delegation budget so the breadth cap is per
1060
+ # turn, not per session. Best-effort: never let a stale/absent bundle break a
1061
+ # turn (recursion is still capped structurally regardless of this reset).
1062
+ try:
1063
+ from crews.assistant_delegate.main import reset_delegation_budget
1064
+ reset_delegation_budget()
1065
+ except Exception:
1066
+ pass
1067
+
958
1068
  # Browser turns carry a WALL-CLOCK budget on top of the round budget
959
1069
  # (plan 0.3): the per-turn grant is short-lived and a wedged page must not
960
1070
  # pin the host. Cancellation uses the same task.cancel() path as STOP.
@@ -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.41",
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
  },