@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.
- package/dist/assistantHost.py +128 -18
- package/dist/connection.js +108 -0
- package/dist/localDbProbe.py +252 -0
- package/localDbProbe.py +252 -0
- package/package.json +3 -2
package/dist/assistantHost.py
CHANGED
|
@@ -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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
package/dist/connection.js
CHANGED
|
@@ -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()
|
package/localDbProbe.py
ADDED
|
@@ -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.
|
|
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
|
},
|