@melaya/runner 1.0.76 → 1.0.78

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.
@@ -0,0 +1,210 @@
1
+ """
2
+ Melaya Assistant — persistent runner-side host.
3
+ ================================================
4
+
5
+ A LONG-LIVED process the runner spawns once per assistant chat session and keeps
6
+ alive across turns (unlike a pipeline run, which is one fire-and-forget
7
+ subprocess). It boots a single ReActAgent whose model runs on THIS machine
8
+ (claude_code / codex read the user's local OAuth off disk; ollama / lmstudio hit
9
+ localhost), then reads one user message per line on stdin, runs a turn, and
10
+ streams structured events on stdout. The agent's memory persists in-process, so
11
+ multi-turn conversation state (and the local model session) survives between
12
+ turns — that is the whole reason this host exists.
13
+
14
+ Protocol
15
+ --------
16
+ stdin (one JSON object per line): {"turnId": "...", "message": "..."}
17
+ stdout (one JSON per line, prefixed): MELASSIST {"turnId","kind",...}
18
+ kind ∈ ready | round | tool | delta | text | done | error
19
+
20
+ Tenant safety: the toolkit is the read-only `melaya_agent_*` tools, which POST to
21
+ the server's assistant-tool bridge authed by MELAYA_API_KEY — every query is
22
+ tenant-gated SERVER-side, so this host gains no data authority beyond the user's
23
+ own mk_ key. Identity is never taken from tool args.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import queue
30
+ import sys
31
+ import threading
32
+ import time
33
+ import traceback
34
+
35
+ # The runner stages the shared/ tree and sets PYTHONPATH before spawning us, so
36
+ # `shared.*` imports resolve exactly like a pipeline subprocess.
37
+ _EVENT_PREFIX = "MELASSIST "
38
+ _IDLE_EXIT_SECONDS = int(os.environ.get("MEL_ASSISTANT_IDLE_SECONDS", "900")) # 15 min
39
+
40
+
41
+ def _emit(turn_id: str, kind: str, **fields) -> None:
42
+ """Write one structured event line to stdout (flushed) for the runner to relay."""
43
+ try:
44
+ payload = {"turnId": turn_id, "kind": kind, **fields}
45
+ sys.stdout.write(_EVENT_PREFIX + json.dumps(payload, ensure_ascii=False, default=str) + "\n")
46
+ sys.stdout.flush()
47
+ except Exception:
48
+ # Never let event emission crash the turn loop.
49
+ pass
50
+
51
+
52
+ def _log(msg: str) -> None:
53
+ # Diagnostics go to stderr (the runner logs stderr; stdout is the event channel).
54
+ try:
55
+ sys.stderr.write(f"[assistantHost] {msg}\n")
56
+ sys.stderr.flush()
57
+ except Exception:
58
+ pass
59
+
60
+
61
+ def _extract_text(result) -> str:
62
+ """Pull the final assistant text out of an agentscope reply Msg."""
63
+ if result is None:
64
+ return ""
65
+ # agentscope Msg exposes get_text_content(); fall back to raw content.
66
+ for attr in ("get_text_content",):
67
+ fn = getattr(result, attr, None)
68
+ if callable(fn):
69
+ try:
70
+ txt = fn()
71
+ if txt:
72
+ return str(txt)
73
+ except Exception:
74
+ pass
75
+ content = getattr(result, "content", result)
76
+ if isinstance(content, str):
77
+ return content
78
+ if isinstance(content, list):
79
+ parts = []
80
+ for block in content:
81
+ if isinstance(block, dict) and block.get("type") == "text":
82
+ parts.append(str(block.get("text", "")))
83
+ elif isinstance(block, str):
84
+ parts.append(block)
85
+ if parts:
86
+ return "\n".join(parts)
87
+ return str(content) if content is not None else ""
88
+
89
+
90
+ def _build_agent():
91
+ """Build the single long-lived ReActAgent for this session."""
92
+ from shared.runtime.agent_factory import make_agent
93
+ from shared.runtime.registry import build_toolkit
94
+
95
+ provider = os.environ.get("MEL_ASSISTANT_PROVIDER", "claude_code")
96
+ model = os.environ.get("MEL_ASSISTANT_MODEL", "") or None
97
+ language = os.environ.get("MEL_ASSISTANT_LANGUAGE", "en")
98
+
99
+ # Read-only platform tools (pipelines, runs, costs, usage, templates, evals).
100
+ # They POST to /api/v1/private/assistant-tool with MELAYA_API_KEY; tenant
101
+ # scope is enforced server-side.
102
+ try:
103
+ toolkit = build_toolkit(categories=["melaya_agent"])
104
+ except Exception as exc:
105
+ _log(f"build_toolkit(melaya_agent) failed: {exc}; starting tool-less")
106
+ toolkit = build_toolkit(names=[])
107
+
108
+ sys_prompt = (
109
+ "You are the Melaya Assistant, the in-app copilot of the Melaya "
110
+ "agent-orchestration platform. You have READ-ONLY tools over the data "
111
+ "this user is allowed to see: pipelines, runs, LLM costs, plan usage, "
112
+ "validated workflow templates and eval results.\n"
113
+ "Rules:\n"
114
+ "- Use tools to answer questions about the user's pipelines, spend, usage, "
115
+ "templates or evals — never invent numbers.\n"
116
+ "- Be concise and concrete; format small tables or bullet lists when comparing items.\n"
117
+ "- If a question is outside the platform, answer normally without tools.\n"
118
+ + (f"- Answer in the user's language: {language}.\n" if language and language != "en" else "")
119
+ )
120
+
121
+ agent = make_agent(
122
+ name="MelayaAssistant",
123
+ sys_prompt=sys_prompt,
124
+ toolkit=toolkit,
125
+ model_name=model,
126
+ provider=provider,
127
+ api_key="", # model.py resolves the provider's own creds (OAuth off disk / localhost)
128
+ max_iters=6,
129
+ reliability=True,
130
+ bounded_memory=True,
131
+ )
132
+ return agent
133
+
134
+
135
+ def _run_turn(agent, turn_id: str, message: str) -> None:
136
+ import asyncio
137
+ from agentscope.message import Msg
138
+
139
+ _emit(turn_id, "round", n=1)
140
+
141
+ async def _go():
142
+ return await agent(Msg("user", message, "user"))
143
+
144
+ try:
145
+ result = asyncio.run(_go())
146
+ except Exception as exc:
147
+ _log("turn error:\n" + traceback.format_exc())
148
+ _emit(turn_id, "error", message=str(exc) or "assistant turn failed")
149
+ _emit(turn_id, "done")
150
+ return
151
+
152
+ text = _extract_text(result)
153
+ _emit(turn_id, "text", content=text)
154
+ _emit(turn_id, "done")
155
+
156
+
157
+ def _stdin_reader(q: "queue.Queue[str]") -> None:
158
+ """Feed stdin lines into a queue so the main loop can apply an idle timeout
159
+ (blocking readline can't be interrupted portably)."""
160
+ try:
161
+ for line in sys.stdin:
162
+ q.put(line)
163
+ except Exception:
164
+ pass
165
+ finally:
166
+ q.put("") # sentinel: stdin closed
167
+
168
+
169
+ def main() -> int:
170
+ _log(f"booting (provider={os.environ.get('MEL_ASSISTANT_PROVIDER')})")
171
+ try:
172
+ agent = _build_agent()
173
+ except Exception as exc:
174
+ _log("boot failed:\n" + traceback.format_exc())
175
+ _emit("", "error", message=f"assistant host failed to start: {exc}")
176
+ return 1
177
+
178
+ _emit("", "ready")
179
+ _log("ready")
180
+
181
+ q: "queue.Queue[str]" = queue.Queue()
182
+ threading.Thread(target=_stdin_reader, args=(q,), daemon=True).start()
183
+
184
+ while True:
185
+ try:
186
+ line = q.get(timeout=_IDLE_EXIT_SECONDS)
187
+ except queue.Empty:
188
+ _log("idle timeout — exiting")
189
+ return 0
190
+ if line == "": # stdin closed
191
+ _log("stdin closed — exiting")
192
+ return 0
193
+ line = line.strip()
194
+ if not line:
195
+ continue
196
+ try:
197
+ req = json.loads(line)
198
+ except Exception:
199
+ _log(f"bad stdin line (not json): {line[:120]}")
200
+ continue
201
+ turn_id = str(req.get("turnId") or "")
202
+ message = str(req.get("message") or "")
203
+ if not message:
204
+ _emit(turn_id, "done")
205
+ continue
206
+ _run_turn(agent, turn_id, message)
207
+
208
+
209
+ if __name__ == "__main__":
210
+ sys.exit(main())
package/dist/cli.d.ts CHANGED
@@ -11,6 +11,6 @@
11
11
  * Security: this package is a thin executor. It does NOT contain any
12
12
  * Melaya business logic, server code, or proprietary modules. The
13
13
  * Python shared/ modules needed for pipeline execution are fetched
14
- * at runtime from the server (integrity-verified via SHA-256 hash).
14
+ * at runtime from the server (authenticated and verified before writing).
15
15
  */
16
16
  export {};
package/dist/cli.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * Security: this package is a thin executor. It does NOT contain any
12
12
  * Melaya business logic, server code, or proprietary modules. The
13
13
  * Python shared/ modules needed for pipeline execution are fetched
14
- * at runtime from the server (integrity-verified via SHA-256 hash).
14
+ * at runtime from the server (authenticated and verified before writing).
15
15
  */
16
16
  import { Command } from "commander";
17
17
  import chalk from "chalk";
@@ -19,4 +19,5 @@ interface ConnectOpts {
19
19
  verbose: boolean;
20
20
  }
21
21
  export declare function connect(opts: ConnectOpts): Promise<void>;
22
+ export declare function verifySignature(content: string, signature: string, runnerToken: string): boolean;
22
23
  export {};
@@ -18,6 +18,7 @@ import { writeFileSync, mkdirSync, existsSync } from "fs";
18
18
  import { dirname, join } from "path";
19
19
  import { fileURLToPath } from "url";
20
20
  import { tmpdir, homedir } from "os";
21
+ import { createHash, createHmac, timingSafeEqual } from "crypto";
21
22
  import { startLocalRelay, setActiveProject } from "./localRelay.js";
22
23
  // ESM equivalent of __dirname — required for the rag:ingest handler to
23
24
  // locate the bundled localRagIngest.py inside the npm package.
@@ -27,6 +28,8 @@ import { ensureSharedModules, getSharedDir } from "./sharedVendor.js";
27
28
  import { startLumaBrowserBridge } from "./lumaBrowserBridge.js";
28
29
  const HEARTBEAT_INTERVAL = 30_000;
29
30
  const activeProcesses = new Map();
31
+ const activeAssistants = new Map();
32
+ const ASSISTANT_IDLE_MS = 15 * 60 * 1000;
30
33
  export async function connect(opts) {
31
34
  const spinner = ora("Connecting to Melaya...").start();
32
35
  // Connect to the /api/v1/runner NAMESPACE on the existing Socket.IO server.
@@ -65,6 +68,10 @@ export async function connect(opts) {
65
68
  spinner.succeed(chalk.green("Connected to Melaya"));
66
69
  // Report detected models
67
70
  socket.emit("runner:models", opts.models);
71
+ // Advertise capabilities so the server only routes assistant chat sessions
72
+ // to runners new enough to host them (older runners never get one → the
73
+ // server falls back with a clear "update your runner" instead of hanging).
74
+ socket.emit("runner:hello", { capabilities: ["assistant_session"], package: "@melaya/runner" });
68
75
  // Start local event relay
69
76
  if (!relay) {
70
77
  relay = await startLocalRelay(socket, opts.verbose, opts.serverUrl);
@@ -102,13 +109,13 @@ export async function connect(opts) {
102
109
  console.log(chalk.hex("#7C6FF0")(`\n ▶ Running pipeline: ${payload.pipelineName} (${payload.runId.slice(0, 10)}...)`));
103
110
  try {
104
111
  // Verify code signature
105
- if (!verifySignature(payload.mainPyContent, payload.signature)) {
112
+ if (!verifySignature(payload.mainPyContent, payload.signature, opts.token)) {
106
113
  console.log(chalk.red(" ✗ Code signature verification failed — refusing to execute"));
107
114
  socket.emit("runner:runComplete", { runId: payload.runId, status: "failed" });
108
115
  return;
109
116
  }
110
117
  // Ensure shared modules are cached
111
- await ensureSharedModules(opts.serverUrl, payload.sharedVersion);
118
+ await ensureSharedModules(opts.serverUrl, payload.sharedVersion, opts.token);
112
119
  // Ensure the dedicated venv at ~/.melaya-runner/venv/ has
113
120
  // agentscope + its transitive deps installed. Without this, the
114
121
  // operator's system python3 typically doesn't have shortuuid /
@@ -535,7 +542,7 @@ export async function connect(opts) {
535
542
  // `shared.runtime.trading_crew_personas` (per the codegen
536
543
  // template's import_path) which lives in the same shared/ tree
537
544
  // the pipeline runner uses.
538
- await ensureSharedModules(opts.serverUrl, sharedVersion);
545
+ await ensureSharedModules(opts.serverUrl, sharedVersion, opts.token);
539
546
  // Dedicated venv with agentscope + anthropic + openai installed.
540
547
  const { ensurePythonEnv } = await import("./pythonEnv.js");
541
548
  const envResult = await ensurePythonEnv(opts.pythonPath, sharedVersion, (msg) => {
@@ -787,6 +794,149 @@ export async function connect(opts) {
787
794
  socket.emit("runner:runComplete", { runId: payload.run_id, status: "failed" });
788
795
  }
789
796
  });
797
+ // ── Persistent Assistant chat sessions (runner-hosted models) ───────────
798
+ // The server drives a per-user chat session on THIS runner for claude_code /
799
+ // codex / ollama / lmstudio: assistant_start boots a long-lived host,
800
+ // assistant_turn feeds one user message, and the host streams events back as
801
+ // runner:assistant_event frames the server relays into the browser SSE.
802
+ socket.on("runner:assistant_start", async (payload) => {
803
+ const sid = String(payload.sessionId || "");
804
+ if (!sid)
805
+ return;
806
+ const emitEv = (ev) => socket.emit("runner:assistant_event", { sessionId: sid, turnId: "", ...ev });
807
+ if (activeAssistants.has(sid)) {
808
+ emitEv({ kind: "ready" });
809
+ return;
810
+ }
811
+ try {
812
+ const sharedVersion = String(payload.sharedVersion ?? "latest");
813
+ await ensureSharedModules(opts.serverUrl, sharedVersion, opts.token);
814
+ const { ensurePythonEnv } = await import("./pythonEnv.js");
815
+ const envResult = await ensurePythonEnv(opts.pythonPath, sharedVersion, (m) => { if (opts.verbose)
816
+ console.log(chalk.gray(` [assistant venv] ${m}`)); });
817
+ if (!envResult.ok) {
818
+ emitEv({ kind: "error", message: `python env failed: ${envResult.reason}` });
819
+ return;
820
+ }
821
+ const { copyFileSync } = await import("fs");
822
+ const hostCandidates = [join(__dirname, "assistantHost.py"), join(__dirname, "..", "assistantHost.py")];
823
+ const hostScript = hostCandidates.find((c) => existsSync(c)) || "";
824
+ if (!hostScript) {
825
+ emitEv({ kind: "error", message: "assistantHost.py not found — reinstall @melaya/runner" });
826
+ return;
827
+ }
828
+ // Sanitize the session id for a filesystem path — it is
829
+ // `userId:conversationId`, and ':' is illegal in a Windows dir name.
830
+ const safeSid = sid.replace(/[^A-Za-z0-9._-]/g, "_");
831
+ const workDir = join(tmpdir(), `melaya-assistant-${safeSid}`);
832
+ mkdirSync(workDir, { recursive: true });
833
+ const stagedHost = join(workDir, "assistantHost.py");
834
+ copyFileSync(hostScript, stagedHost);
835
+ const sharedDir = getSharedDir();
836
+ const certBundle = (await import("./pythonEnv.js")).getCertBundlePath();
837
+ const sslEnv = certBundle ? { SSL_CERT_FILE: certBundle, REQUESTS_CA_BUNDLE: certBundle } : {};
838
+ const httpBase = opts.serverUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
839
+ const env = {
840
+ ...process.env,
841
+ PYTHONPATH: sharedDir,
842
+ PYTHONIOENCODING: "utf-8",
843
+ PYTHONUTF8: "1",
844
+ ...sslEnv,
845
+ MEL_STUDIO_URL: httpBase,
846
+ MELAYA_API_BASE: httpBase,
847
+ MELAYA_API_URL: httpBase,
848
+ LMSTUDIO_BASE_URL: "http://127.0.0.1:1234",
849
+ OLLAMA_BASE_URL: "http://127.0.0.1:11434",
850
+ MEL_ASSISTANT_PROVIDER: String(payload.provider || "claude_code"),
851
+ MEL_ASSISTANT_MODEL: String(payload.model || ""),
852
+ MEL_ASSISTANT_LANGUAGE: String(payload.language || "en"),
853
+ // Per-user creds (incl. MELAYA_API_KEY for the tenant-gated tool bridge).
854
+ ...(payload.credentials || {}),
855
+ };
856
+ const proc = spawn(envResult.pythonPath, ["-u", stagedHost], { cwd: workDir, env, stdio: ["pipe", "pipe", "pipe"] });
857
+ const session = { proc, lastActivity: Date.now(), stdoutBuf: "" };
858
+ activeAssistants.set(sid, session);
859
+ console.log(chalk.hex("#7c6ff0")(` ◆ Assistant session ${sid.slice(0, 10)}… (${payload.provider})`));
860
+ proc.stdout?.on("data", (data) => {
861
+ session.stdoutBuf += data.toString();
862
+ let idx;
863
+ while ((idx = session.stdoutBuf.indexOf("\n")) >= 0) {
864
+ const line = session.stdoutBuf.slice(0, idx);
865
+ session.stdoutBuf = session.stdoutBuf.slice(idx + 1);
866
+ const t = line.trim();
867
+ if (t.startsWith("MELASSIST ")) {
868
+ try {
869
+ socket.emit("runner:assistant_event", { sessionId: sid, ...JSON.parse(t.slice("MELASSIST ".length)) });
870
+ }
871
+ catch { /* skip malformed */ }
872
+ }
873
+ else if (t && opts.verbose) {
874
+ console.log(chalk.gray(` [assistant-stdout] ${t}`));
875
+ }
876
+ }
877
+ });
878
+ const stderrTail = [];
879
+ proc.stderr?.on("data", (data) => {
880
+ for (const ln of data.toString().split("\n")) {
881
+ const t = ln.trimEnd();
882
+ if (!t)
883
+ continue;
884
+ if (opts.verbose)
885
+ console.log(chalk.gray(` [assistant-stderr] ${t}`));
886
+ stderrTail.push(t);
887
+ if (stderrTail.length > 40)
888
+ stderrTail.shift();
889
+ }
890
+ });
891
+ proc.on("exit", (code) => {
892
+ activeAssistants.delete(sid);
893
+ const detail = code !== 0 && stderrTail.length ? stderrTail.slice(-12).join("\n") : "";
894
+ socket.emit("runner:assistant_event", { sessionId: sid, turnId: "", kind: "session_closed", code, detail });
895
+ console.log(chalk.gray(` ■ Assistant session ${sid.slice(0, 10)}… closed (exit ${code})`));
896
+ });
897
+ }
898
+ catch (e) {
899
+ emitEv({ kind: "error", message: `assistant start failed: ${e?.message || e}` });
900
+ }
901
+ });
902
+ socket.on("runner:assistant_turn", (payload) => {
903
+ const s = activeAssistants.get(String(payload.sessionId || ""));
904
+ if (!s) {
905
+ socket.emit("runner:assistant_event", { sessionId: payload.sessionId, turnId: payload.turnId, kind: "error", message: "session_not_found" });
906
+ return;
907
+ }
908
+ s.lastActivity = Date.now();
909
+ try {
910
+ s.proc.stdin?.write(JSON.stringify({ turnId: payload.turnId, message: payload.message }) + "\n");
911
+ }
912
+ catch (e) {
913
+ socket.emit("runner:assistant_event", { sessionId: payload.sessionId, turnId: payload.turnId, kind: "error", message: `turn write failed: ${e?.message || e}` });
914
+ }
915
+ });
916
+ socket.on("runner:assistant_stop", (payload) => {
917
+ const s = activeAssistants.get(String(payload.sessionId || ""));
918
+ if (s) {
919
+ try {
920
+ s.proc.kill();
921
+ }
922
+ catch { /* noop */ }
923
+ activeAssistants.delete(String(payload.sessionId));
924
+ }
925
+ });
926
+ // Idle sweep — reap assistant hosts with no turn in ASSISTANT_IDLE_MS.
927
+ setInterval(() => {
928
+ const now = Date.now();
929
+ for (const [sid, s] of activeAssistants) {
930
+ if (now - s.lastActivity > ASSISTANT_IDLE_MS) {
931
+ try {
932
+ s.proc.kill();
933
+ }
934
+ catch { /* noop */ }
935
+ activeAssistants.delete(sid);
936
+ console.log(chalk.gray(` ■ Assistant session ${sid.slice(0, 10)}… idle-swept`));
937
+ }
938
+ }
939
+ }, 60_000);
790
940
  // ── RAG Mode B: local-folder ingest (privacy-preserving) ─────────────
791
941
  // The user picks "Local folder" in the Agent Builder DocsTab and types
792
942
  // a path on their machine (e.g. C:\Users\me\Documents\refs). The server
@@ -908,7 +1058,7 @@ export async function connect(opts) {
908
1058
  }
909
1059
  // Stage the ingest script into the venv work dir so the spawned
910
1060
  // python sees the shared.runtime + the agentscope vendored tree.
911
- await ensureSharedModules(opts.serverUrl, payload.sharedVersion);
1061
+ await ensureSharedModules(opts.serverUrl, payload.sharedVersion, opts.token);
912
1062
  const sharedDir = getSharedDir();
913
1063
  const ingestScript = join(sharedDir, "..", "localRagIngest.py");
914
1064
  // The script is published alongside the runner's shared bundle in
@@ -1109,7 +1259,7 @@ export async function connect(opts) {
1109
1259
  });
1110
1260
  return;
1111
1261
  }
1112
- await ensureSharedModules(opts.serverUrl, payload.sharedVersion);
1262
+ await ensureSharedModules(opts.serverUrl, payload.sharedVersion, opts.token);
1113
1263
  const sharedDir = getSharedDir();
1114
1264
  const workDir = join(tmpdir(), `melaya-rag-retrieve-${payload.pipelineName}-${Date.now()}`);
1115
1265
  mkdirSync(workDir, { recursive: true });
@@ -1333,6 +1483,13 @@ export async function connect(opts) {
1333
1483
  proc.kill("SIGTERM");
1334
1484
  socket.emit("runner:runComplete", { runId: id, status: "failed" });
1335
1485
  }
1486
+ for (const [, s] of activeAssistants) {
1487
+ try {
1488
+ s.proc.kill("SIGTERM");
1489
+ }
1490
+ catch { /* noop */ }
1491
+ }
1492
+ activeAssistants.clear();
1336
1493
  relay?.close();
1337
1494
  // Best-effort Chromium teardown. The bridge's shutdown is async but
1338
1495
  // we can't await inside a SIGINT handler — fire and forget; the
@@ -1346,14 +1503,11 @@ export async function connect(opts) {
1346
1503
  process.on("SIGINT", cleanup);
1347
1504
  process.on("SIGTERM", cleanup);
1348
1505
  }
1349
- function verifySignature(content, signature) {
1350
- // The signature is an HMAC-SHA256 computed by the server using JWT_SECRET.
1351
- // The runner doesn't have the secret — but we verify the signature is
1352
- // present and non-empty. A future version will use a pre-shared public key
1353
- // for full client-side verification. For now, the signature's presence
1354
- // proves the payload passed through the server (not injected by MITM).
1355
- if (!signature || signature.length < 32) {
1506
+ export function verifySignature(content, signature, runnerToken) {
1507
+ if (!content || !signature || !runnerToken)
1356
1508
  return false;
1357
- }
1358
- return true;
1509
+ const signingKey = createHash("sha256").update(runnerToken).digest();
1510
+ const expected = createHmac("sha256", signingKey).update(content).digest();
1511
+ const received = Buffer.from(signature, "hex");
1512
+ return received.length === expected.length && timingSafeEqual(received, expected);
1359
1513
  }
package/dist/pythonEnv.js CHANGED
@@ -117,6 +117,16 @@ const PIP_DEPS = [
117
117
  // tool module cleanly — this just makes the dep present so the yf_ tools work
118
118
  // when attached. Pulls pandas transitively.
119
119
  "yfinance",
120
+ // ── Template-validation sweep deps (2026-07-06) — tools that hard-error
121
+ // "pip install X" on the runner without these, observed in live runs:
122
+ // yt-dlp — youtube_search_channel / youtube_get_transcript (1221 YouTube Intel)
123
+ // sentence-transformers — build_knowledge_from_text embedder (1221)
124
+ // flights — flight_price_alert / flight_search live Google Flights ("fli" import; 1261/1265)
125
+ // pymupdf4llm — pdf_to_text markdown extraction (1227 PDF Contract Analyser)
126
+ "yt-dlp",
127
+ "sentence-transformers",
128
+ "flights",
129
+ "pymupdf4llm",
120
130
  // ── Vector RAG (retrieval mode) ────────────────────────────────────────
121
131
  // ollama — Python client used by agentscope.embedding.OllamaTextEmbedding.
122
132
  // Talks to localhost:11434 (the user's local Ollama daemon) to embed
@@ -8,4 +8,4 @@
8
8
  * integrity-verified via SHA-256, and cached at ~/.melaya-runner/shared/.
9
9
  */
10
10
  export declare function getSharedDir(): string;
11
- export declare function ensureSharedModules(serverUrl: string, expectedVersion: string): Promise<void>;
11
+ export declare function ensureSharedModules(serverUrl: string, expectedVersion: string, runnerToken: string): Promise<void>;
@@ -10,7 +10,7 @@
10
10
  import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "fs";
11
11
  import { join } from "path";
12
12
  import { homedir } from "os";
13
- import { createHash } from "crypto";
13
+ import { createHash, createHmac, timingSafeEqual } from "crypto";
14
14
  const CACHE_DIR = join(homedir(), ".melaya-runner");
15
15
  const SHARED_DIR = join(CACHE_DIR, "shared");
16
16
  const VERSION_FILE = join(CACHE_DIR, "shared-version.txt");
@@ -35,7 +35,7 @@ const CANARY_FILES = [
35
35
  function cacheLooksValid() {
36
36
  return CANARY_FILES.every((p) => existsSync(p));
37
37
  }
38
- export async function ensureSharedModules(serverUrl, expectedVersion) {
38
+ export async function ensureSharedModules(serverUrl, expectedVersion, runnerToken) {
39
39
  mkdirSync(CACHE_DIR, { recursive: true });
40
40
  // Check cached version. We require BOTH a version match AND a valid nested
41
41
  // layout — older runner versions wrote flat filenames into shared/, and
@@ -57,7 +57,10 @@ export async function ensureSharedModules(serverUrl, expectedVersion) {
57
57
  // Fetch from server
58
58
  const httpUrl = serverUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
59
59
  const url = `${httpUrl}/api/v1/runner/shared-bundle?v=${expectedVersion}`;
60
- const res = await fetch(url, { signal: AbortSignal.timeout(30_000) });
60
+ const res = await fetch(url, {
61
+ headers: { Authorization: `Bearer ${runnerToken}` },
62
+ signal: AbortSignal.timeout(30_000),
63
+ });
61
64
  if (!res.ok) {
62
65
  throw new Error(`Failed to fetch shared modules: HTTP ${res.status}`);
63
66
  }
@@ -67,6 +70,12 @@ export async function ensureSharedModules(serverUrl, expectedVersion) {
67
70
  if (computed !== body.hash) {
68
71
  throw new Error("Shared module integrity check failed — hash mismatch");
69
72
  }
73
+ const signingKey = createHash("sha256").update(runnerToken).digest();
74
+ const expectedSignature = createHmac("sha256", signingKey).update(body.hash).digest();
75
+ const receivedSignature = Buffer.from(body.signature || "", "hex");
76
+ if (receivedSignature.length !== expectedSignature.length || !timingSafeEqual(receivedSignature, expectedSignature)) {
77
+ throw new Error("Shared module authenticity check failed: signature mismatch");
78
+ }
70
79
  // Write files (sanitize filenames to prevent path traversal)
71
80
  const writtenDirs = new Set();
72
81
  for (const [filename, content] of Object.entries(body.files)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.0.76",
3
+ "version": "1.0.78",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -19,7 +19,7 @@
19
19
  "README.md"
20
20
  ],
21
21
  "scripts": {
22
- "build": "tsc && node -e \"const fs=require('fs'); fs.copyFileSync('localRagIngest.py','dist/localRagIngest.py'); fs.copyFileSync('localRagRetrieve.py','dist/localRagRetrieve.py')\"",
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
23
  "prepublishOnly": "npm run build"
24
24
  },
25
25
  "dependencies": {