@melaya/runner 1.1.49 → 1.1.51
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 +6 -1
- package/dist/browserBridge.js +73 -3
- package/dist/browserGrantVerify.js +5 -5
- package/dist/cli.js +16 -1
- package/dist/connection.js +130 -2
- package/dist/installReport.d.ts +12 -0
- package/dist/installReport.js +76 -0
- package/dist/localBacklinkFetch.py +138 -0
- package/localBacklinkFetch.py +138 -0
- package/package.json +3 -2
package/dist/assistantHost.py
CHANGED
|
@@ -170,6 +170,8 @@ def _config_hash() -> str:
|
|
|
170
170
|
pinned_tools = ",".join(sorted(t.lower() for t in raw_pinned.split(",") if t.strip()))
|
|
171
171
|
if pinned_tools:
|
|
172
172
|
fields.append(pinned_tools)
|
|
173
|
+
if os.environ.get("MEL_ASSISTANT_TELEMETRY_SURFACE") == "marketing":
|
|
174
|
+
fields.append("marketing-scoped-tools-v1")
|
|
173
175
|
canon = "|".join(fields)
|
|
174
176
|
return hashlib.sha256(canon.encode("utf-8")).hexdigest()[:32]
|
|
175
177
|
|
|
@@ -607,6 +609,7 @@ def _build_agent():
|
|
|
607
609
|
# names are case-sensitive Python identifiers (unlike connector_services,
|
|
608
610
|
# which are lowercased service ids). Registered ACTIVE at boot below so the
|
|
609
611
|
# model calls them directly instead of search_tools/activate_tool.
|
|
612
|
+
marketing_tool_scope = os.environ.get("MEL_ASSISTANT_TELEMETRY_SURFACE") == "marketing"
|
|
610
613
|
pinned_tool_names = [t.strip() for t in os.environ.get("MEL_ASSISTANT_PINNED_TOOLS", "").split(",") if t.strip()]
|
|
611
614
|
# Self-arm the write-approval gate whenever connectors are active, so it can't
|
|
612
615
|
# be silently OFF on an older runner build that didn't set this env. Writes
|
|
@@ -659,11 +662,12 @@ def _build_agent():
|
|
|
659
662
|
include_categories=categories + core_categories + connector_services,
|
|
660
663
|
budget=_budget,
|
|
661
664
|
pinned_names=pinned_tool_names or None,
|
|
665
|
+
allowed_names=pinned_tool_names if marketing_tool_scope else None,
|
|
662
666
|
)
|
|
663
667
|
except Exception as exc:
|
|
664
668
|
_log(f"toolkit build failed (connectors={connector_services}, core): {exc}; retrying melaya_agent only")
|
|
665
669
|
try:
|
|
666
|
-
toolkit = build_toolkit(categories=["melaya_agent"])
|
|
670
|
+
toolkit = build_toolkit(names=[]) if marketing_tool_scope else build_toolkit(categories=["melaya_agent"])
|
|
667
671
|
except Exception:
|
|
668
672
|
toolkit = build_toolkit(names=[])
|
|
669
673
|
|
|
@@ -1302,6 +1306,7 @@ def main() -> int:
|
|
|
1302
1306
|
except Exception:
|
|
1303
1307
|
_log("restore failed:\n" + traceback.format_exc())
|
|
1304
1308
|
continue
|
|
1309
|
+
os.environ["MEL_APPROVAL_CONVERSATION_ID"] = str(req.get("conversation_id") or "")
|
|
1305
1310
|
turn_id = str(req.get("turnId") or "")
|
|
1306
1311
|
message = str(req.get("message") or "")
|
|
1307
1312
|
# Vision: base64 image blocks for THIS turn (validated server-side). Used
|
package/dist/browserBridge.js
CHANGED
|
@@ -214,6 +214,58 @@ async function humanType(page, text) {
|
|
|
214
214
|
await page.waitForTimeout(Math.round(d));
|
|
215
215
|
}
|
|
216
216
|
}
|
|
217
|
+
// Minimal, safe HTML rendering of plain text for the clipboard `text/html`
|
|
218
|
+
// flavor: preserves paragraph and line breaks so a paste into Google Docs /
|
|
219
|
+
// rich editors keeps structure instead of collapsing to one line.
|
|
220
|
+
function htmlFromPlain(t) {
|
|
221
|
+
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
222
|
+
return t.split(/\n{2,}/).map((p) => `<p>${esc(p).replace(/\n/g, "<br>")}</p>`).join("") || "<p></p>";
|
|
223
|
+
}
|
|
224
|
+
// One-shot bulk text insert (the "paste" mode of input_text). Char-by-char
|
|
225
|
+
// humanType is ~100ms/char (700s+ on an article), lands out of order in a live-
|
|
226
|
+
// re-rendering editor, and never reaches a Google Docs <canvas> at all. This
|
|
227
|
+
// writes the whole string to the clipboard (text/plain + text/html) and fires a
|
|
228
|
+
// real Ctrl/Cmd+V, which every surface — <input>, <textarea>, contenteditable,
|
|
229
|
+
// and the Docs canvas — imports in a single operation with formatting intact.
|
|
230
|
+
// Returns "paste" on success, or null when the clipboard is unavailable
|
|
231
|
+
// (permission / non-secure context) so the caller can fall back to CDP
|
|
232
|
+
// Input.insertText. The caller has already focused the field and run the
|
|
233
|
+
// secret-field / publish gates.
|
|
234
|
+
async function pasteBulkText(page, text) {
|
|
235
|
+
try {
|
|
236
|
+
const origin = new URL(page.url()).origin;
|
|
237
|
+
if (origin && origin !== "null") {
|
|
238
|
+
await page.context().grantPermissions(["clipboard-read", "clipboard-write"], { origin }).catch(() => { });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
catch { /* opaque / non-http origin: grantPermissions would throw */ }
|
|
242
|
+
const wrote = await page.evaluate(async (args) => {
|
|
243
|
+
try {
|
|
244
|
+
const nav = navigator;
|
|
245
|
+
const CI = globalThis.ClipboardItem;
|
|
246
|
+
if (nav.clipboard?.write && CI) {
|
|
247
|
+
await nav.clipboard.write([new CI({
|
|
248
|
+
"text/plain": new Blob([args.t], { type: "text/plain" }),
|
|
249
|
+
"text/html": new Blob([args.h], { type: "text/html" }),
|
|
250
|
+
})]);
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
if (nav.clipboard?.writeText) {
|
|
254
|
+
await nav.clipboard.writeText(args.t);
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
}, { t: text, h: htmlFromPlain(text) }).catch(() => false);
|
|
263
|
+
if (wrote) {
|
|
264
|
+
await page.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V");
|
|
265
|
+
return "paste";
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
217
269
|
// Break a wheel scroll into a few smaller, jittered steps with pauses.
|
|
218
270
|
async function humanScroll(page, dx, dy) {
|
|
219
271
|
const steps = _randInt(3, 6);
|
|
@@ -1558,11 +1610,29 @@ export async function startBrowserBridge(opts) {
|
|
|
1558
1610
|
"Hand off to the human with browser_ask_user so they enter the secret; never type credentials.");
|
|
1559
1611
|
}
|
|
1560
1612
|
await humanClick(page, pt.x, pt.y);
|
|
1561
|
-
// Clear-then-type: select-all
|
|
1613
|
+
// Clear-then-type: select-all first so both paths REPLACE the field.
|
|
1562
1614
|
await page.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A").catch(() => { });
|
|
1563
1615
|
await humanPause(page, 40, 120);
|
|
1564
|
-
|
|
1565
|
-
|
|
1616
|
+
// Entry strategy. "human" = per-char cadence (anti-bot, short fields);
|
|
1617
|
+
// "paste" = one-shot bulk insert; omitted/"auto" = paste for long text
|
|
1618
|
+
// (char-by-char would blow the 20s tool timeout and scramble on a live
|
|
1619
|
+
// editor / never reach a Docs canvas). Short text keeps humanType so the
|
|
1620
|
+
// anti-bot cadence and site keystroke handlers still fire.
|
|
1621
|
+
const mode = String(args.mode ?? "auto");
|
|
1622
|
+
const usePaste = mode === "paste" || (mode !== "human" && text.length > 300);
|
|
1623
|
+
if (!usePaste) {
|
|
1624
|
+
await humanType(page, text);
|
|
1625
|
+
return { typed: text.length, via: "human" };
|
|
1626
|
+
}
|
|
1627
|
+
let via = await pasteBulkText(page, text);
|
|
1628
|
+
if (!via) {
|
|
1629
|
+
// Clipboard blocked: CDP whole-string insert (inputs/textarea/
|
|
1630
|
+
// contenteditable; not canvas). getCdp is in scope here.
|
|
1631
|
+
const cdp = await getCdp(rec, page);
|
|
1632
|
+
await cdpRace(cdp.send("Input.insertText", { text }), "Input.insertText");
|
|
1633
|
+
via = "insertText";
|
|
1634
|
+
}
|
|
1635
|
+
return { typed: text.length, via };
|
|
1566
1636
|
}
|
|
1567
1637
|
case "press_key": {
|
|
1568
1638
|
const key = String(args.key || "");
|
|
@@ -148,11 +148,11 @@ export const DEFAULT_CLOCK_TOLERANCE_SECONDS = 30;
|
|
|
148
148
|
* longer life than the issuer can mint is forged or corrupt, so it is
|
|
149
149
|
* rejected regardless of signature.
|
|
150
150
|
*
|
|
151
|
-
* Raised 900s -> 4h alongside the server
|
|
152
|
-
* runner, a server deployed ahead of
|
|
153
|
-
*
|
|
154
|
-
* same time as, the server change. */
|
|
155
|
-
const MAX_LIFETIME_SECONDS =
|
|
151
|
+
* Raised 900s -> 4h -> 7d alongside the server (persistent week-long browser
|
|
152
|
+
* sessions). Because this half ships in the runner, a server deployed ahead of
|
|
153
|
+
* a runner republish would mint grants this rejects with claims_invalid —
|
|
154
|
+
* publish the runner before, or at the same time as, the server change. */
|
|
155
|
+
const MAX_LIFETIME_SECONDS = 7 * 24 * 60 * 60;
|
|
156
156
|
function b64urlJson(part, what) {
|
|
157
157
|
let buf;
|
|
158
158
|
try {
|
package/dist/cli.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { Command } from "commander";
|
|
17
17
|
import chalk from "chalk";
|
|
18
|
+
import { reportInstallFailure } from "./installReport.js";
|
|
18
19
|
import { connect } from "./connection.js";
|
|
19
20
|
import { detectModels } from "./detect.js";
|
|
20
21
|
// Logo colors: cyan → blue → indigo gradient (matching melaya.org)
|
|
@@ -63,6 +64,7 @@ async function main() {
|
|
|
63
64
|
console.log(chalk.red(` ✗ A Melaya runner is already running on this machine (pid ${lock.ownerPid}).`));
|
|
64
65
|
console.log(chalk.gray(" Only one runner can run per machine. Stop the other one first"));
|
|
65
66
|
console.log(chalk.gray(` (e.g. \`kill ${lock.ownerPid}\`, or close its terminal), then start again.`));
|
|
67
|
+
await reportInstallFailure("single_instance_locked", { token: opts.token, server: opts.server });
|
|
66
68
|
process.exit(1);
|
|
67
69
|
}
|
|
68
70
|
// Detect Python. Fast path: a supported host interpreter (3.10–3.12). If the
|
|
@@ -87,6 +89,10 @@ async function main() {
|
|
|
87
89
|
console.log(chalk.gray(" macOS: brew install python@3.12"));
|
|
88
90
|
console.log(chalk.gray(" Linux: sudo apt install python3.12 python3.12-venv (or: pyenv install 3.12)"));
|
|
89
91
|
console.log(chalk.gray(" Windows: winget install Python.Python.3.12"));
|
|
92
|
+
await reportInstallFailure("python_missing", {
|
|
93
|
+
token: opts.token, server: opts.server,
|
|
94
|
+
detail: py.foundVersion ? `host=${py.foundVersion}` : "none",
|
|
95
|
+
});
|
|
90
96
|
process.exit(1);
|
|
91
97
|
}
|
|
92
98
|
python = provisioned;
|
|
@@ -185,7 +191,16 @@ async function bootstrap() {
|
|
|
185
191
|
}
|
|
186
192
|
await main();
|
|
187
193
|
}
|
|
188
|
-
bootstrap().catch((e) => {
|
|
194
|
+
bootstrap().catch(async (e) => {
|
|
189
195
|
console.error(chalk.red(`\n Fatal: ${e.message}\n`));
|
|
196
|
+
// Token is parsed inside main(); recover it from argv so a crash BEFORE the
|
|
197
|
+
// socket still reports. Only the STAGE is sent — never the error text, which
|
|
198
|
+
// routinely carries file paths and usernames.
|
|
199
|
+
const tokArg = process.argv.find((a) => a.startsWith("--token"));
|
|
200
|
+
const srvArg = process.argv.find((a) => a.startsWith("--server"));
|
|
201
|
+
await reportInstallFailure("bootstrap_fatal", {
|
|
202
|
+
token: tokArg ? tokArg.replace(/^--token[= ]?/, "") : undefined,
|
|
203
|
+
server: srvArg ? srvArg.replace(/^--server[= ]?/, "") : undefined,
|
|
204
|
+
});
|
|
190
205
|
process.exit(1);
|
|
191
206
|
});
|
package/dist/connection.js
CHANGED
|
@@ -303,11 +303,20 @@ export async function connect(opts) {
|
|
|
303
303
|
void _prewarmAssistantEnv();
|
|
304
304
|
console.log(chalk.gray(" Waiting for pipeline runs...\n"));
|
|
305
305
|
});
|
|
306
|
+
// A runner that never connects is invisible to the funnel: last_seen stays
|
|
307
|
+
// NULL and the reason dies on the user's machine. Report the stage before
|
|
308
|
+
// exiting — auth-rejected and unreachable are very different fixes.
|
|
309
|
+
let reportedConnectFailure = false;
|
|
306
310
|
socket.on("connect_error", (err) => {
|
|
307
311
|
spinner.fail(chalk.red(`Connection failed: ${err.message}`));
|
|
308
|
-
|
|
312
|
+
const authRejected = err.message.includes("Authentication");
|
|
313
|
+
if (!reportedConnectFailure) {
|
|
314
|
+
reportedConnectFailure = true;
|
|
315
|
+
void import("./installReport.js").then((m) => m.reportInstallFailure(authRejected ? "socket_auth_rejected" : "socket_unreachable", { token: opts.token, server: opts.serverUrl })).catch(() => { });
|
|
316
|
+
}
|
|
317
|
+
if (authRejected) {
|
|
309
318
|
console.log(chalk.yellow(" Token may be expired or revoked. Generate a new one in Settings → Connectors."));
|
|
310
|
-
process.exit(1);
|
|
319
|
+
setTimeout(() => process.exit(1), 300); // let the beacon leave the box
|
|
311
320
|
}
|
|
312
321
|
});
|
|
313
322
|
socket.on("disconnect", (reason) => {
|
|
@@ -1884,6 +1893,7 @@ export async function connect(opts) {
|
|
|
1884
1893
|
message: payload.message,
|
|
1885
1894
|
hitl_mode: hitlMode,
|
|
1886
1895
|
static_context: typeof payload.staticContext === "string" ? payload.staticContext : "",
|
|
1896
|
+
conversation_id: String(payload.sessionId || "").split(":").slice(1).join(":"),
|
|
1887
1897
|
telemetry_surface: typeof payload.telemetrySurface === "string" ? payload.telemetrySurface : "assistant",
|
|
1888
1898
|
// Per-turn browser grant: forwarded to the host as the raw compact
|
|
1889
1899
|
// JWS string so the Python browser toolkit can verify it again
|
|
@@ -2473,6 +2483,124 @@ export async function connect(opts) {
|
|
|
2473
2483
|
reply(false, undefined, e?.message || String(e));
|
|
2474
2484
|
}
|
|
2475
2485
|
});
|
|
2486
|
+
// ── Residential-IP page fetch (Melaya backlink presence check) ──────────────
|
|
2487
|
+
// The Melaya cloud runs from a datacenter IP that Cloudflare "managed" Turnstile
|
|
2488
|
+
// forces into an interactive challenge no headless engine clears. THIS machine
|
|
2489
|
+
// is on a residential IP, where the same real-browser fetch passes in seconds.
|
|
2490
|
+
// The server hands us ONE public https url; we fetch it with the local Scrapling
|
|
2491
|
+
// ladder (localBacklinkFetch.py) and return the HTML for the server's presence
|
|
2492
|
+
// scan. We receive only the url — no secrets, no internal hosts, no catalog data.
|
|
2493
|
+
socket.on("runner:backlink-fetch", async (payload, ack) => {
|
|
2494
|
+
const rid = payload?.requestId;
|
|
2495
|
+
if (!rid)
|
|
2496
|
+
return;
|
|
2497
|
+
ack?.({ ok: true });
|
|
2498
|
+
const reply = (r) => socket.emit("runner:backlink-fetch-result", {
|
|
2499
|
+
requestId: rid,
|
|
2500
|
+
ok: !r.error && !!r.html && !r.challenged,
|
|
2501
|
+
status: r.status ?? 0,
|
|
2502
|
+
engine: r.engine ?? null,
|
|
2503
|
+
challenged: !!r.challenged,
|
|
2504
|
+
html: r.html ?? "",
|
|
2505
|
+
finalUrl: r.finalUrl ?? payload.url,
|
|
2506
|
+
error: r.error,
|
|
2507
|
+
});
|
|
2508
|
+
// HTTPS-only, public url — defence in depth (the server already validated it).
|
|
2509
|
+
let u = null;
|
|
2510
|
+
try {
|
|
2511
|
+
u = new URL(payload.url);
|
|
2512
|
+
}
|
|
2513
|
+
catch {
|
|
2514
|
+
u = null;
|
|
2515
|
+
}
|
|
2516
|
+
if (!u || u.protocol !== "https:") {
|
|
2517
|
+
reply({ error: "invalid_url" });
|
|
2518
|
+
return;
|
|
2519
|
+
}
|
|
2520
|
+
try {
|
|
2521
|
+
const { ensurePythonEnv } = await import("./pythonEnv.js");
|
|
2522
|
+
const { getLocalSharedVersion } = await import("./sharedVendor.js");
|
|
2523
|
+
const localVersion = getLocalSharedVersion();
|
|
2524
|
+
if (!localVersion) {
|
|
2525
|
+
reply({ error: "runner python runtime still setting up" });
|
|
2526
|
+
return;
|
|
2527
|
+
}
|
|
2528
|
+
const env = await ensurePythonEnv(opts.pythonPath, localVersion, (m) => { if (opts.verbose)
|
|
2529
|
+
console.log(chalk.gray(` [backlink-fetch venv] ${m}`)); });
|
|
2530
|
+
if (!env.ok) {
|
|
2531
|
+
reply({ error: `venv bootstrap failed: ${env.reason}` });
|
|
2532
|
+
return;
|
|
2533
|
+
}
|
|
2534
|
+
const { existsSync, copyFileSync, mkdirSync } = await import("fs");
|
|
2535
|
+
const workDir = join(tmpdir(), `melaya-blf-${Date.now()}`);
|
|
2536
|
+
mkdirSync(workDir, { recursive: true });
|
|
2537
|
+
const candidates = [
|
|
2538
|
+
join(__dirname, "localBacklinkFetch.py"),
|
|
2539
|
+
join(__dirname, "..", "localBacklinkFetch.py"),
|
|
2540
|
+
];
|
|
2541
|
+
let found = "";
|
|
2542
|
+
for (const c of candidates) {
|
|
2543
|
+
if (existsSync(c)) {
|
|
2544
|
+
found = c;
|
|
2545
|
+
break;
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
if (!found) {
|
|
2549
|
+
reply({ error: "localBacklinkFetch.py not found — update @melaya/runner" });
|
|
2550
|
+
return;
|
|
2551
|
+
}
|
|
2552
|
+
const staged = join(workDir, "localBacklinkFetch.py");
|
|
2553
|
+
copyFileSync(found, staged);
|
|
2554
|
+
const certBundle = (await import("./pythonEnv.js")).getCertBundlePath();
|
|
2555
|
+
const sslEnv = certBundle
|
|
2556
|
+
? { SSL_CERT_FILE: certBundle, REQUESTS_CA_BUNDLE: certBundle } : {};
|
|
2557
|
+
const budget = Math.max(6, Math.min(75, Math.floor(payload.budgetSeconds || 40)));
|
|
2558
|
+
const proc = spawn(env.pythonPath, ["-u", staged], {
|
|
2559
|
+
env: { ...process.env, ...sslEnv },
|
|
2560
|
+
cwd: workDir,
|
|
2561
|
+
});
|
|
2562
|
+
let stdout = "";
|
|
2563
|
+
let stderr = "";
|
|
2564
|
+
let done = false;
|
|
2565
|
+
const finish = (r) => { if (done)
|
|
2566
|
+
return; done = true; reply(r); };
|
|
2567
|
+
// Hard wall-clock cap slightly above the fetch budget so a wedged browser
|
|
2568
|
+
// can't hold the session; the server has its own (budget+8s) timeout too.
|
|
2569
|
+
const killTimer = setTimeout(() => {
|
|
2570
|
+
try {
|
|
2571
|
+
proc.kill();
|
|
2572
|
+
}
|
|
2573
|
+
catch { /* already gone */ }
|
|
2574
|
+
finish({ error: "fetch timed out" });
|
|
2575
|
+
}, (budget + 10) * 1000);
|
|
2576
|
+
proc.stdout.on("data", (d) => { stdout += d.toString("utf-8"); });
|
|
2577
|
+
proc.stderr.on("data", (d) => { stderr += d.toString("utf-8"); });
|
|
2578
|
+
proc.on("error", (err) => { clearTimeout(killTimer); finish({ error: err?.message || String(err) }); });
|
|
2579
|
+
proc.on("close", (code) => {
|
|
2580
|
+
clearTimeout(killTimer);
|
|
2581
|
+
const jsonLine = stdout.split(/\r?\n/).map(s => s.trim()).filter(Boolean).reverse()[0] || "";
|
|
2582
|
+
let parsed = null;
|
|
2583
|
+
try {
|
|
2584
|
+
parsed = JSON.parse(jsonLine);
|
|
2585
|
+
}
|
|
2586
|
+
catch { /* handled below */ }
|
|
2587
|
+
if (parsed && typeof parsed === "object") {
|
|
2588
|
+
finish({ status: parsed.status, engine: parsed.engine, challenged: !!parsed.challenged, html: parsed.html || "", finalUrl: parsed.finalUrl });
|
|
2589
|
+
}
|
|
2590
|
+
else {
|
|
2591
|
+
finish({ error: `fetch exited ${code ?? "?"}${stderr ? `: ${stderr.slice(-200)}` : ""}` });
|
|
2592
|
+
}
|
|
2593
|
+
});
|
|
2594
|
+
try {
|
|
2595
|
+
proc.stdin.write(JSON.stringify({ url: u.href, budget_seconds: budget }));
|
|
2596
|
+
proc.stdin.end();
|
|
2597
|
+
}
|
|
2598
|
+
catch { /* proc.on('error') handles it */ }
|
|
2599
|
+
}
|
|
2600
|
+
catch (e) {
|
|
2601
|
+
reply({ error: e?.message || String(e) });
|
|
2602
|
+
}
|
|
2603
|
+
});
|
|
2476
2604
|
socket.on("linkedin:start-login", async (req) => {
|
|
2477
2605
|
const sid = req?.session_id;
|
|
2478
2606
|
if (!sid)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** The point at which the runner gave up. Keep in lockstep with the server's
|
|
2
|
+
* META_ALLOW entry for `runner_install_failed`. */
|
|
3
|
+
export type InstallFailStage = "single_instance_locked" | "python_missing" | "socket_auth_rejected" | "socket_unreachable" | "bootstrap_fatal";
|
|
4
|
+
/**
|
|
5
|
+
* Best-effort telemetry for a runner that is about to exit without connecting.
|
|
6
|
+
* Awaited only so the process does not die mid-flight; it resolves either way.
|
|
7
|
+
*/
|
|
8
|
+
export declare function reportInstallFailure(stage: InstallFailStage, opts?: {
|
|
9
|
+
token?: string;
|
|
10
|
+
server?: string;
|
|
11
|
+
detail?: string;
|
|
12
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// packages/runner/src/installReport.ts
|
|
2
|
+
//
|
|
3
|
+
// Report WHY the runner never came up.
|
|
4
|
+
//
|
|
5
|
+
// The activation funnel could see that runner tokens were being issued and that
|
|
6
|
+
// nobody ever connected — measured 2026-09-14 over 48h: **9 tokens issued, 0
|
|
7
|
+
// connections, 0 local runs** — but not one reason, because every failure path
|
|
8
|
+
// here ends in `process.exit(1)` on the user's own machine and the server never
|
|
9
|
+
// hears about it. `runner_tokens.last_seen` stays NULL and that is the entire
|
|
10
|
+
// signal. 9 → 0 is not actionable; "6 of 9 had no usable Python" is.
|
|
11
|
+
//
|
|
12
|
+
// This closes that gap with a single fire-and-forget POST before the exit.
|
|
13
|
+
//
|
|
14
|
+
// Rules it must obey, because it runs while something is already going wrong:
|
|
15
|
+
// - NEVER throws, never blocks shutdown, hard 3s timeout.
|
|
16
|
+
// - Sends a STAGE CODE, not a message. No file paths, no usernames, no
|
|
17
|
+
// hostnames, no error strings — those carry the user's directory layout and
|
|
18
|
+
// sometimes their name. The stage plus a coarse platform is what makes the
|
|
19
|
+
// number actionable; the rest is someone's home directory.
|
|
20
|
+
// - Honours MELAYA_RUNNER_NO_TELEMETRY=1.
|
|
21
|
+
// - Authenticated by the runner token the user already passed, so the server
|
|
22
|
+
// can attribute it to the account without any new credential.
|
|
23
|
+
let alreadySent = false;
|
|
24
|
+
/**
|
|
25
|
+
* Best-effort telemetry for a runner that is about to exit without connecting.
|
|
26
|
+
* Awaited only so the process does not die mid-flight; it resolves either way.
|
|
27
|
+
*/
|
|
28
|
+
export async function reportInstallFailure(stage, opts = {}) {
|
|
29
|
+
try {
|
|
30
|
+
if (alreadySent)
|
|
31
|
+
return; // one report per launch
|
|
32
|
+
alreadySent = true;
|
|
33
|
+
if (process.env.MELAYA_RUNNER_NO_TELEMETRY === "1")
|
|
34
|
+
return;
|
|
35
|
+
const token = String(opts.token || "").trim();
|
|
36
|
+
if (!token)
|
|
37
|
+
return; // nothing to attribute it to
|
|
38
|
+
const base = String(opts.server || "https://api.melaya.org").replace(/\/+$/, "");
|
|
39
|
+
// Version is read the same way connection.ts does; a dev tree without a
|
|
40
|
+
// package.json simply reports "0.0.0".
|
|
41
|
+
let version = "0.0.0";
|
|
42
|
+
try {
|
|
43
|
+
const { readFileSync } = await import("node:fs");
|
|
44
|
+
const { join, dirname } = await import("node:path");
|
|
45
|
+
const { fileURLToPath } = await import("node:url");
|
|
46
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
47
|
+
version = String(JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8")).version || "0.0.0");
|
|
48
|
+
}
|
|
49
|
+
catch { /* non-fatal */ }
|
|
50
|
+
const ac = new AbortController();
|
|
51
|
+
const timer = setTimeout(() => ac.abort(), 3000);
|
|
52
|
+
try {
|
|
53
|
+
await fetch(`${base}/api/v1/runner/install-report`, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
56
|
+
// `detail` is a SHORT, bounded, non-identifying hint (e.g. the
|
|
57
|
+
// Python version actually found) — never a path or an error dump.
|
|
58
|
+
body: JSON.stringify({
|
|
59
|
+
stage,
|
|
60
|
+
version,
|
|
61
|
+
platform: process.platform, // darwin | win32 | linux
|
|
62
|
+
arch: process.arch,
|
|
63
|
+
node: process.versions.node,
|
|
64
|
+
...(opts.detail ? { detail: String(opts.detail).slice(0, 60) } : {}),
|
|
65
|
+
}),
|
|
66
|
+
signal: ac.signal,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
/* The runner is already failing. Telemetry must never make that worse. */
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Residential-IP page fetch for the Melaya backlink presence check.
|
|
3
|
+
|
|
4
|
+
The Melaya cloud runs from a datacenter IP that Cloudflare "managed" Turnstile
|
|
5
|
+
forces into an interactive challenge no headless engine clears. The user's
|
|
6
|
+
runner is on a RESIDENTIAL IP, where the same real-browser fetch passes in
|
|
7
|
+
seconds. So the server hands us ONE public https url; we fetch it with the
|
|
8
|
+
local Scrapling stack and print the result as a single JSON line on stdout:
|
|
9
|
+
|
|
10
|
+
{"status": int, "engine": str|null, "challenged": bool,
|
|
11
|
+
"finalUrl": str, "html": str} # html capped
|
|
12
|
+
|
|
13
|
+
Input (stdin, JSON): {"url": "...", "budget_seconds": 40}
|
|
14
|
+
|
|
15
|
+
Self-contained on purpose (mirrors localDbProbe.py): it imports only Scrapling
|
|
16
|
+
from the runner venv, never the shared tool bundle. HTTPS-only; the server
|
|
17
|
+
already validated the url, this is defence in depth. Never fetches anything but
|
|
18
|
+
the url it is given — no internal hosts, no secrets, no catalog data.
|
|
19
|
+
"""
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
_HTML_CAP = 600_000
|
|
25
|
+
_CHALLENGE_MARKERS = (
|
|
26
|
+
"just a moment", "cf_chl", "cf-challenge", "checking your browser",
|
|
27
|
+
"enable javascript and cookies", "attention required",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _html_of(page) -> str:
|
|
32
|
+
for attr in ("html_content", "body", "content"):
|
|
33
|
+
try:
|
|
34
|
+
v = getattr(page, attr)
|
|
35
|
+
if callable(v):
|
|
36
|
+
v = v()
|
|
37
|
+
if v:
|
|
38
|
+
return v if isinstance(v, str) else str(v)
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
try:
|
|
42
|
+
return str(page)
|
|
43
|
+
except Exception:
|
|
44
|
+
return ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _challenged(h: str) -> bool:
|
|
48
|
+
h = (h or "").lower()
|
|
49
|
+
return any(m in h for m in _CHALLENGE_MARKERS)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def _fetch(url: str, budget: float) -> dict:
|
|
53
|
+
ms = int(max(6.0, min(75.0, budget)) * 1000)
|
|
54
|
+
# ── Tier 1: TLS-impersonating async GET (no browser) ──
|
|
55
|
+
try:
|
|
56
|
+
from scrapling.fetchers import AsyncFetcher
|
|
57
|
+
p = await AsyncFetcher.get(url, timeout=min(ms, 15000), impersonate="chrome",
|
|
58
|
+
stealthy_headers=True, follow_redirects=True)
|
|
59
|
+
st = int(getattr(p, "status", 0) or 0)
|
|
60
|
+
h = _html_of(p)
|
|
61
|
+
final = str(getattr(p, "url", "") or url)
|
|
62
|
+
if 200 <= st < 300 and h and not _challenged(h):
|
|
63
|
+
return {"status": st, "engine": "fast", "challenged": False,
|
|
64
|
+
"finalUrl": final, "html": h[:_HTML_CAP]}
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
# ── Tier 2/3: stealth browser (real Chrome), plain then solve_cloudflare ──
|
|
69
|
+
try:
|
|
70
|
+
from scrapling.fetchers import StealthyFetcher
|
|
71
|
+
except Exception:
|
|
72
|
+
return {"status": 0, "engine": None, "challenged": False, "finalUrl": url, "html": ""}
|
|
73
|
+
fn = getattr(StealthyFetcher, "async_fetch", None)
|
|
74
|
+
if fn is None:
|
|
75
|
+
return {"status": 0, "engine": None, "challenged": False, "finalUrl": url, "html": ""}
|
|
76
|
+
|
|
77
|
+
last = {"status": 0, "engine": "stealth", "challenged": True, "finalUrl": url, "html": ""}
|
|
78
|
+
for solve in (False, True):
|
|
79
|
+
base = {"headless": True, "timeout": 60000 if solve else min(ms, 25000)}
|
|
80
|
+
if solve:
|
|
81
|
+
base.update(solve_cloudflare=True, block_webrtc=True, disable_resources=False)
|
|
82
|
+
page = None
|
|
83
|
+
engine = ("real_chrome_cf" if solve else "real_chrome")
|
|
84
|
+
for use_real in (True, False):
|
|
85
|
+
kw = dict(base)
|
|
86
|
+
if use_real:
|
|
87
|
+
kw["real_chrome"] = True
|
|
88
|
+
else:
|
|
89
|
+
engine = ("chromium_cf" if solve else "chromium")
|
|
90
|
+
try:
|
|
91
|
+
page = await fn(url, **kw)
|
|
92
|
+
break
|
|
93
|
+
except Exception:
|
|
94
|
+
page = None
|
|
95
|
+
continue
|
|
96
|
+
if page is None:
|
|
97
|
+
continue
|
|
98
|
+
h = _html_of(page)
|
|
99
|
+
st = int(getattr(page, "status", 0) or 0)
|
|
100
|
+
final = str(getattr(page, "url", "") or url)
|
|
101
|
+
last = {"status": st, "engine": engine, "challenged": _challenged(h),
|
|
102
|
+
"finalUrl": final, "html": (h or "")[:_HTML_CAP]}
|
|
103
|
+
if h and not _challenged(h):
|
|
104
|
+
return last
|
|
105
|
+
if not solve and not _challenged(h):
|
|
106
|
+
break # plain stealth returned something non-challenge-y but empty
|
|
107
|
+
return last
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def _main() -> None:
|
|
111
|
+
try:
|
|
112
|
+
data = json.loads(sys.stdin.read() or "{}")
|
|
113
|
+
except Exception:
|
|
114
|
+
data = {}
|
|
115
|
+
url = str(data.get("url") or "").strip()
|
|
116
|
+
try:
|
|
117
|
+
budget = float(data.get("budget_seconds") or 40)
|
|
118
|
+
except Exception:
|
|
119
|
+
budget = 40.0
|
|
120
|
+
from urllib.parse import urlsplit
|
|
121
|
+
if not url or urlsplit(url).scheme != "https":
|
|
122
|
+
print(json.dumps({"status": 0, "engine": None, "challenged": False,
|
|
123
|
+
"finalUrl": url, "html": "", "error": "https_only"}))
|
|
124
|
+
return
|
|
125
|
+
try:
|
|
126
|
+
out = await _fetch(url, budget)
|
|
127
|
+
except Exception as e:
|
|
128
|
+
out = {"status": 0, "engine": None, "challenged": False,
|
|
129
|
+
"finalUrl": url, "html": "", "error": str(e)[:200]}
|
|
130
|
+
print(json.dumps(out))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if sys.platform.startswith("win"):
|
|
134
|
+
try:
|
|
135
|
+
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
|
136
|
+
except Exception:
|
|
137
|
+
pass
|
|
138
|
+
asyncio.run(_main())
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Residential-IP page fetch for the Melaya backlink presence check.
|
|
3
|
+
|
|
4
|
+
The Melaya cloud runs from a datacenter IP that Cloudflare "managed" Turnstile
|
|
5
|
+
forces into an interactive challenge no headless engine clears. The user's
|
|
6
|
+
runner is on a RESIDENTIAL IP, where the same real-browser fetch passes in
|
|
7
|
+
seconds. So the server hands us ONE public https url; we fetch it with the
|
|
8
|
+
local Scrapling stack and print the result as a single JSON line on stdout:
|
|
9
|
+
|
|
10
|
+
{"status": int, "engine": str|null, "challenged": bool,
|
|
11
|
+
"finalUrl": str, "html": str} # html capped
|
|
12
|
+
|
|
13
|
+
Input (stdin, JSON): {"url": "...", "budget_seconds": 40}
|
|
14
|
+
|
|
15
|
+
Self-contained on purpose (mirrors localDbProbe.py): it imports only Scrapling
|
|
16
|
+
from the runner venv, never the shared tool bundle. HTTPS-only; the server
|
|
17
|
+
already validated the url, this is defence in depth. Never fetches anything but
|
|
18
|
+
the url it is given — no internal hosts, no secrets, no catalog data.
|
|
19
|
+
"""
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
_HTML_CAP = 600_000
|
|
25
|
+
_CHALLENGE_MARKERS = (
|
|
26
|
+
"just a moment", "cf_chl", "cf-challenge", "checking your browser",
|
|
27
|
+
"enable javascript and cookies", "attention required",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _html_of(page) -> str:
|
|
32
|
+
for attr in ("html_content", "body", "content"):
|
|
33
|
+
try:
|
|
34
|
+
v = getattr(page, attr)
|
|
35
|
+
if callable(v):
|
|
36
|
+
v = v()
|
|
37
|
+
if v:
|
|
38
|
+
return v if isinstance(v, str) else str(v)
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
try:
|
|
42
|
+
return str(page)
|
|
43
|
+
except Exception:
|
|
44
|
+
return ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _challenged(h: str) -> bool:
|
|
48
|
+
h = (h or "").lower()
|
|
49
|
+
return any(m in h for m in _CHALLENGE_MARKERS)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def _fetch(url: str, budget: float) -> dict:
|
|
53
|
+
ms = int(max(6.0, min(75.0, budget)) * 1000)
|
|
54
|
+
# ── Tier 1: TLS-impersonating async GET (no browser) ──
|
|
55
|
+
try:
|
|
56
|
+
from scrapling.fetchers import AsyncFetcher
|
|
57
|
+
p = await AsyncFetcher.get(url, timeout=min(ms, 15000), impersonate="chrome",
|
|
58
|
+
stealthy_headers=True, follow_redirects=True)
|
|
59
|
+
st = int(getattr(p, "status", 0) or 0)
|
|
60
|
+
h = _html_of(p)
|
|
61
|
+
final = str(getattr(p, "url", "") or url)
|
|
62
|
+
if 200 <= st < 300 and h and not _challenged(h):
|
|
63
|
+
return {"status": st, "engine": "fast", "challenged": False,
|
|
64
|
+
"finalUrl": final, "html": h[:_HTML_CAP]}
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
# ── Tier 2/3: stealth browser (real Chrome), plain then solve_cloudflare ──
|
|
69
|
+
try:
|
|
70
|
+
from scrapling.fetchers import StealthyFetcher
|
|
71
|
+
except Exception:
|
|
72
|
+
return {"status": 0, "engine": None, "challenged": False, "finalUrl": url, "html": ""}
|
|
73
|
+
fn = getattr(StealthyFetcher, "async_fetch", None)
|
|
74
|
+
if fn is None:
|
|
75
|
+
return {"status": 0, "engine": None, "challenged": False, "finalUrl": url, "html": ""}
|
|
76
|
+
|
|
77
|
+
last = {"status": 0, "engine": "stealth", "challenged": True, "finalUrl": url, "html": ""}
|
|
78
|
+
for solve in (False, True):
|
|
79
|
+
base = {"headless": True, "timeout": 60000 if solve else min(ms, 25000)}
|
|
80
|
+
if solve:
|
|
81
|
+
base.update(solve_cloudflare=True, block_webrtc=True, disable_resources=False)
|
|
82
|
+
page = None
|
|
83
|
+
engine = ("real_chrome_cf" if solve else "real_chrome")
|
|
84
|
+
for use_real in (True, False):
|
|
85
|
+
kw = dict(base)
|
|
86
|
+
if use_real:
|
|
87
|
+
kw["real_chrome"] = True
|
|
88
|
+
else:
|
|
89
|
+
engine = ("chromium_cf" if solve else "chromium")
|
|
90
|
+
try:
|
|
91
|
+
page = await fn(url, **kw)
|
|
92
|
+
break
|
|
93
|
+
except Exception:
|
|
94
|
+
page = None
|
|
95
|
+
continue
|
|
96
|
+
if page is None:
|
|
97
|
+
continue
|
|
98
|
+
h = _html_of(page)
|
|
99
|
+
st = int(getattr(page, "status", 0) or 0)
|
|
100
|
+
final = str(getattr(page, "url", "") or url)
|
|
101
|
+
last = {"status": st, "engine": engine, "challenged": _challenged(h),
|
|
102
|
+
"finalUrl": final, "html": (h or "")[:_HTML_CAP]}
|
|
103
|
+
if h and not _challenged(h):
|
|
104
|
+
return last
|
|
105
|
+
if not solve and not _challenged(h):
|
|
106
|
+
break # plain stealth returned something non-challenge-y but empty
|
|
107
|
+
return last
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def _main() -> None:
|
|
111
|
+
try:
|
|
112
|
+
data = json.loads(sys.stdin.read() or "{}")
|
|
113
|
+
except Exception:
|
|
114
|
+
data = {}
|
|
115
|
+
url = str(data.get("url") or "").strip()
|
|
116
|
+
try:
|
|
117
|
+
budget = float(data.get("budget_seconds") or 40)
|
|
118
|
+
except Exception:
|
|
119
|
+
budget = 40.0
|
|
120
|
+
from urllib.parse import urlsplit
|
|
121
|
+
if not url or urlsplit(url).scheme != "https":
|
|
122
|
+
print(json.dumps({"status": 0, "engine": None, "challenged": False,
|
|
123
|
+
"finalUrl": url, "html": "", "error": "https_only"}))
|
|
124
|
+
return
|
|
125
|
+
try:
|
|
126
|
+
out = await _fetch(url, budget)
|
|
127
|
+
except Exception as e:
|
|
128
|
+
out = {"status": 0, "engine": None, "challenged": False,
|
|
129
|
+
"finalUrl": url, "html": "", "error": str(e)[:200]}
|
|
130
|
+
print(json.dumps(out))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if sys.platform.startswith("win"):
|
|
134
|
+
try:
|
|
135
|
+
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
|
136
|
+
except Exception:
|
|
137
|
+
pass
|
|
138
|
+
asyncio.run(_main())
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@melaya/runner",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.51",
|
|
4
4
|
"description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
@@ -16,11 +16,12 @@
|
|
|
16
16
|
"localRagIngest.py",
|
|
17
17
|
"localRagRetrieve.py",
|
|
18
18
|
"localDbProbe.py",
|
|
19
|
+
"localBacklinkFetch.py",
|
|
19
20
|
"nltk_data/**",
|
|
20
21
|
"README.md"
|
|
21
22
|
],
|
|
22
23
|
"scripts": {
|
|
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')\"",
|
|
24
|
+
"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('localBacklinkFetch.py','dist/localBacklinkFetch.py'); fs.copyFileSync('src/assistantHost.py','dist/assistantHost.py')\"",
|
|
24
25
|
"test": "node --test --import tsx \"src/**/*.test.ts\"",
|
|
25
26
|
"prepublishOnly": "npm run build"
|
|
26
27
|
},
|