@melaya/runner 1.0.21 → 1.0.24

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.
@@ -19,6 +19,7 @@ import { join } from "path";
19
19
  import { tmpdir } from "os";
20
20
  import { startLocalRelay, setActiveProject } from "./localRelay.js";
21
21
  import { ensureSharedModules, getSharedDir } from "./sharedVendor.js";
22
+ import { startLumaBrowserBridge } from "./lumaBrowserBridge.js";
22
23
  const HEARTBEAT_INTERVAL = 30_000;
23
24
  const activeProcesses = new Map();
24
25
  export async function connect(opts) {
@@ -36,6 +37,25 @@ export async function connect(opts) {
36
37
  reconnectionAttempts: Infinity,
37
38
  });
38
39
  let relay = null;
40
+ // Luma browser bridge — only started after a successful Luma sign-in
41
+ // (storage state file exists). Reset to a fresh bridge after each
42
+ // luma:login-result so cookie rotation is picked up immediately.
43
+ let lumaBridge = null;
44
+ const _ensureLumaBridge = async () => {
45
+ if (lumaBridge)
46
+ return;
47
+ try {
48
+ lumaBridge = await startLumaBrowserBridge({
49
+ log: (m) => {
50
+ if (opts.verbose)
51
+ console.log(chalk.gray(` [luma-bridge] ${m}`));
52
+ },
53
+ });
54
+ }
55
+ catch (e) {
56
+ console.log(chalk.yellow(` ⚠ Luma browser bridge failed to start: ${e?.message || e}`));
57
+ }
58
+ };
39
59
  socket.on("connect", async () => {
40
60
  spinner.succeed(chalk.green("Connected to Melaya"));
41
61
  // Report detected models
@@ -47,6 +67,10 @@ export async function connect(opts) {
47
67
  console.log(chalk.gray(` [relay] Listening on 127.0.0.1:${relay.port} (nonce: ${relay.nonce})`));
48
68
  }
49
69
  }
70
+ // Start the Luma browser bridge if a saved session exists. Best-
71
+ // effort — failure here means Luma POSTs fall back to aiohttp
72
+ // (which 403s under the bot rule) but reads still work.
73
+ await _ensureLumaBridge();
50
74
  console.log(chalk.gray(" Waiting for pipeline runs...\n"));
51
75
  });
52
76
  socket.on("connect_error", (err) => {
@@ -112,6 +136,16 @@ export async function connect(opts) {
112
136
  OLLAMA_BASE_URL: "http://127.0.0.1:11434",
113
137
  // Inject per-pipeline credentials (already scoped by the server)
114
138
  ...payload.credentials,
139
+ // If a Luma browser bridge is up (i.e. the user has signed in
140
+ // via this runner), inject its localhost URL + bearer token so
141
+ // shared/tools/luma.py routes /event/register through Playwright
142
+ // and bypasses Cloudflare's bot rule. Reads keep using aiohttp.
143
+ ...(lumaBridge
144
+ ? {
145
+ MEL_LUMA_BROWSER_URL: lumaBridge.url,
146
+ MEL_LUMA_BROWSER_TOKEN: lumaBridge.token,
147
+ }
148
+ : {}),
115
149
  };
116
150
  // Spawn Python subprocess
117
151
  const proc = spawn(opts.pythonPath, ["-u", join(runDir, "main.py")], {
@@ -207,6 +241,20 @@ export async function connect(opts) {
207
241
  const outcome = await runLumaLoginFlow(emitProgress);
208
242
  console.log(describeOutcome(outcome));
209
243
  socket.emit("luma:login-result", { session_id: sid, ...outcome });
244
+ // Successful sign-in just wrote a fresh storage-state.json. If the
245
+ // bridge is already up, force the next forward to re-read state
246
+ // from disk; if it isn't up (first sign-in on this runner), boot
247
+ // it now so the very next pipeline run has Cloudflare bypass.
248
+ if (outcome.ok) {
249
+ if (lumaBridge) {
250
+ lumaBridge.invalidateState();
251
+ if (opts.verbose)
252
+ console.log(chalk.gray(" [luma-bridge] storage state invalidated; next call will re-read."));
253
+ }
254
+ else {
255
+ await _ensureLumaBridge();
256
+ }
257
+ }
210
258
  }
211
259
  catch (e) {
212
260
  const msg = e?.message || "luma_login_unhandled_error";
@@ -240,6 +288,12 @@ export async function connect(opts) {
240
288
  socket.emit("runner:runComplete", { runId: id, status: "failed" });
241
289
  }
242
290
  relay?.close();
291
+ // Best-effort Chromium teardown. The bridge's shutdown is async but
292
+ // we can't await inside a SIGINT handler — fire and forget; the
293
+ // process.exit below kills any lingering child processes anyway.
294
+ if (lumaBridge) {
295
+ lumaBridge.shutdown().catch(() => { });
296
+ }
243
297
  socket.disconnect();
244
298
  process.exit(0);
245
299
  };
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Luma browser bridge — localhost HTTP server that tunnels HTTP requests
3
+ * through a Playwright Chromium context loaded from the user's saved
4
+ * Luma sign-in state.
5
+ *
6
+ * Why this exists
7
+ * ───────────────
8
+ *
9
+ * Cloudflare's bot rule on `POST /event/register` (and a handful of other
10
+ * write endpoints) is much stricter than on reads. It checks:
11
+ * 1. cf_clearance cookie issued by a managed challenge.
12
+ * 2. cf_bm cookie value freshly minted by JS executed on the page.
13
+ * 3. JA3/JA4 TLS fingerprint matching a real browser.
14
+ * 4. sec-ch-ua / sec-fetch-* header set matching same.
15
+ *
16
+ * Replaying cookies through aiohttp from Python misses #2-#4 and gets a
17
+ * 403 even with a valid session — see run cd0d03d05e684e0f. The reliable
18
+ * fix is to issue the request from inside the SAME Chromium context that
19
+ * captured the session: the JS runs, cookies refresh, headers and TLS
20
+ * fingerprint match, and the bot rule clears.
21
+ *
22
+ * How it works
23
+ * ────────────
24
+ *
25
+ * 1. At Luma sign-in, the Playwright `storageState` (full cookie jar +
26
+ * localStorage + sessionStorage) is saved to `<runner-state-dir>/
27
+ * luma-storage-state.json`. This is the source of truth for the
28
+ * browser session — separate from the cookies persisted to the
29
+ * server's DB (which feed the env-var path used by aiohttp reads).
30
+ * 2. At runner startup, if the storage-state file exists, this module
31
+ * starts a small HTTP server bound to 127.0.0.1 on a random free
32
+ * port, mints a per-session token, and exposes both via env vars
33
+ * `MEL_LUMA_BROWSER_URL` + `MEL_LUMA_BROWSER_TOKEN` to the spawned
34
+ * Python pipeline subprocess.
35
+ * 3. Python's `luma_register_event` POSTs the registration body to
36
+ * `${MEL_LUMA_BROWSER_URL}/luma/forward` with
37
+ * `Authorization: Bearer ${MEL_LUMA_BROWSER_TOKEN}`. The bridge:
38
+ * a. Spawns / reuses a headless Chromium with the saved storage
39
+ * state, freshly reloaded from disk so a recent reconnect's
40
+ * cookies are picked up immediately.
41
+ * b. Navigates to `https://luma.com/${eid}` so Cloudflare's JS
42
+ * executes and refreshes cf_bm/cf_clearance.
43
+ * c. Calls `fetch(url, init)` from inside `page.evaluate(...)`
44
+ * so the request inherits the page's full browser context.
45
+ * d. Returns `{ status, body }` to Python.
46
+ *
47
+ * Security
48
+ * ────────
49
+ *
50
+ * - Server binds to 127.0.0.1 only — never accessible off the host.
51
+ * - Random per-session token in the Authorization header — even other
52
+ * processes on the same machine can't fire requests without it
53
+ * (the env var is only injected into the runner-spawned pipeline
54
+ * subprocess).
55
+ * - Endpoint is narrow: the URL must be a luma.com / api.luma.com /
56
+ * api2.luma.com / lu.ma origin and the method must be GET / POST.
57
+ * Anything else is rejected with 400.
58
+ * - Body size is capped at 64 KB.
59
+ *
60
+ * Cleanup
61
+ * ───────
62
+ *
63
+ * The browser is started lazily on the first request and torn down on
64
+ * runner exit. If the runner is killed mid-request, the next request
65
+ * relaunches.
66
+ */
67
+ export interface LumaBrowserBridge {
68
+ url: string;
69
+ token: string;
70
+ shutdown: () => Promise<void>;
71
+ /** Called from connection.ts after a successful luma:login-result so
72
+ * the next forward picks up the freshly-saved storage state. The
73
+ * bridge always re-reads from disk, so this is purely a hint to
74
+ * drop any reusable browser context (forces re-create on next call). */
75
+ invalidateState: () => void;
76
+ }
77
+ export declare function lumaStorageStatePath(): string;
78
+ /**
79
+ * Starts the bridge if a saved Luma storage state exists. Returns null
80
+ * when no state is present so the runner stays in legacy aiohttp-only
81
+ * mode and Python's diagnostic path correctly reports
82
+ * `cf_cookies_present: []`.
83
+ */
84
+ export declare function startLumaBrowserBridge(opts: {
85
+ log: (msg: string) => void;
86
+ }): Promise<LumaBrowserBridge | null>;
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Luma browser bridge — localhost HTTP server that tunnels HTTP requests
3
+ * through a Playwright Chromium context loaded from the user's saved
4
+ * Luma sign-in state.
5
+ *
6
+ * Why this exists
7
+ * ───────────────
8
+ *
9
+ * Cloudflare's bot rule on `POST /event/register` (and a handful of other
10
+ * write endpoints) is much stricter than on reads. It checks:
11
+ * 1. cf_clearance cookie issued by a managed challenge.
12
+ * 2. cf_bm cookie value freshly minted by JS executed on the page.
13
+ * 3. JA3/JA4 TLS fingerprint matching a real browser.
14
+ * 4. sec-ch-ua / sec-fetch-* header set matching same.
15
+ *
16
+ * Replaying cookies through aiohttp from Python misses #2-#4 and gets a
17
+ * 403 even with a valid session — see run cd0d03d05e684e0f. The reliable
18
+ * fix is to issue the request from inside the SAME Chromium context that
19
+ * captured the session: the JS runs, cookies refresh, headers and TLS
20
+ * fingerprint match, and the bot rule clears.
21
+ *
22
+ * How it works
23
+ * ────────────
24
+ *
25
+ * 1. At Luma sign-in, the Playwright `storageState` (full cookie jar +
26
+ * localStorage + sessionStorage) is saved to `<runner-state-dir>/
27
+ * luma-storage-state.json`. This is the source of truth for the
28
+ * browser session — separate from the cookies persisted to the
29
+ * server's DB (which feed the env-var path used by aiohttp reads).
30
+ * 2. At runner startup, if the storage-state file exists, this module
31
+ * starts a small HTTP server bound to 127.0.0.1 on a random free
32
+ * port, mints a per-session token, and exposes both via env vars
33
+ * `MEL_LUMA_BROWSER_URL` + `MEL_LUMA_BROWSER_TOKEN` to the spawned
34
+ * Python pipeline subprocess.
35
+ * 3. Python's `luma_register_event` POSTs the registration body to
36
+ * `${MEL_LUMA_BROWSER_URL}/luma/forward` with
37
+ * `Authorization: Bearer ${MEL_LUMA_BROWSER_TOKEN}`. The bridge:
38
+ * a. Spawns / reuses a headless Chromium with the saved storage
39
+ * state, freshly reloaded from disk so a recent reconnect's
40
+ * cookies are picked up immediately.
41
+ * b. Navigates to `https://luma.com/${eid}` so Cloudflare's JS
42
+ * executes and refreshes cf_bm/cf_clearance.
43
+ * c. Calls `fetch(url, init)` from inside `page.evaluate(...)`
44
+ * so the request inherits the page's full browser context.
45
+ * d. Returns `{ status, body }` to Python.
46
+ *
47
+ * Security
48
+ * ────────
49
+ *
50
+ * - Server binds to 127.0.0.1 only — never accessible off the host.
51
+ * - Random per-session token in the Authorization header — even other
52
+ * processes on the same machine can't fire requests without it
53
+ * (the env var is only injected into the runner-spawned pipeline
54
+ * subprocess).
55
+ * - Endpoint is narrow: the URL must be a luma.com / api.luma.com /
56
+ * api2.luma.com / lu.ma origin and the method must be GET / POST.
57
+ * Anything else is rejected with 400.
58
+ * - Body size is capped at 64 KB.
59
+ *
60
+ * Cleanup
61
+ * ───────
62
+ *
63
+ * The browser is started lazily on the first request and torn down on
64
+ * runner exit. If the runner is killed mid-request, the next request
65
+ * relaunches.
66
+ */
67
+ import { createServer } from "node:http";
68
+ import { randomBytes } from "node:crypto";
69
+ import { promises as fs } from "node:fs";
70
+ import { existsSync } from "node:fs";
71
+ import path from "node:path";
72
+ import os from "node:os";
73
+ const ALLOWED_HOSTNAMES = new Set([
74
+ "luma.com",
75
+ "www.luma.com",
76
+ "lu.ma",
77
+ "api.luma.com",
78
+ "api2.luma.com",
79
+ ]);
80
+ const MAX_BODY_BYTES = 64 * 1024;
81
+ const PAGE_GOTO_TIMEOUT_MS = 20_000;
82
+ const PAGE_SETTLE_MS = 1500;
83
+ // ── State-file location ─────────────────────────────────────────────────────
84
+ /** Resolves the same `melaya-runner` state dir the rest of the runner uses.
85
+ * We can't share a constant because connection.ts doesn't expose its dir,
86
+ * but the convention is well-defined: ~/.melaya/<sub> on every OS. */
87
+ function _stateDir() {
88
+ return path.join(os.homedir(), ".melaya", "luma");
89
+ }
90
+ export function lumaStorageStatePath() {
91
+ return path.join(_stateDir(), "storage-state.json");
92
+ }
93
+ // ── Public starter ──────────────────────────────────────────────────────────
94
+ /**
95
+ * Starts the bridge if a saved Luma storage state exists. Returns null
96
+ * when no state is present so the runner stays in legacy aiohttp-only
97
+ * mode and Python's diagnostic path correctly reports
98
+ * `cf_cookies_present: []`.
99
+ */
100
+ export async function startLumaBrowserBridge(opts) {
101
+ const statePath = lumaStorageStatePath();
102
+ if (!existsSync(statePath)) {
103
+ opts.log("Luma browser bridge: no saved storage state, skipping.");
104
+ return null;
105
+ }
106
+ // Lazy-load Playwright. If the user installed @melaya/runner without
107
+ // chromium ever being needed (e.g. legacy local-model only), we don't
108
+ // want to crash on startup.
109
+ let playwright;
110
+ try {
111
+ playwright = await import("playwright");
112
+ }
113
+ catch {
114
+ opts.log("Luma browser bridge: playwright module unavailable, skipping.");
115
+ return null;
116
+ }
117
+ const token = randomBytes(24).toString("hex");
118
+ // Persistent browser handle. We launch on first request, not at boot,
119
+ // so the Chromium binary cost (~250 MB resident) is only paid by users
120
+ // who actually run a Luma pipeline. Subsequent requests reuse the
121
+ // browser; only the per-call context is fresh.
122
+ let browser = null;
123
+ let browserLaunching = null;
124
+ let stateMtime = 0;
125
+ async function _ensureBrowser() {
126
+ if (browser && browser.isConnected())
127
+ return browser;
128
+ if (browserLaunching)
129
+ return browserLaunching;
130
+ browserLaunching = (async () => {
131
+ const b = await playwright.chromium.launch({
132
+ headless: true,
133
+ args: [
134
+ "--disable-blink-features=AutomationControlled",
135
+ "--disable-dev-shm-usage",
136
+ ],
137
+ });
138
+ browser = b;
139
+ browserLaunching = null;
140
+ return b;
141
+ })();
142
+ return browserLaunching;
143
+ }
144
+ async function _readStorageState() {
145
+ const stat = await fs.stat(statePath);
146
+ stateMtime = stat.mtimeMs;
147
+ const txt = await fs.readFile(statePath, "utf-8");
148
+ return JSON.parse(txt);
149
+ }
150
+ async function _forward(req) {
151
+ let parsed;
152
+ try {
153
+ parsed = new URL(req.url);
154
+ }
155
+ catch {
156
+ return { ok: false, status: 0, body: "", error: "invalid_url" };
157
+ }
158
+ if (!ALLOWED_HOSTNAMES.has(parsed.hostname)) {
159
+ return { ok: false, status: 0, body: "", error: `host_not_allowed: ${parsed.hostname}` };
160
+ }
161
+ const method = (req.method || "POST").toUpperCase();
162
+ if (method !== "GET" && method !== "POST") {
163
+ return { ok: false, status: 0, body: "", error: `method_not_allowed: ${method}` };
164
+ }
165
+ const b = await _ensureBrowser();
166
+ let ctx = null;
167
+ try {
168
+ const storageState = await _readStorageState();
169
+ ctx = await b.newContext({
170
+ storageState: storageState,
171
+ userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
172
+ "(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
173
+ viewport: { width: 1280, height: 820 },
174
+ });
175
+ const page = await ctx.newPage();
176
+ const warmup = req.warmupUrl || `https://luma.com/`;
177
+ try {
178
+ await page.goto(warmup, {
179
+ waitUntil: "domcontentloaded",
180
+ timeout: req.navigationTimeoutMs || PAGE_GOTO_TIMEOUT_MS,
181
+ });
182
+ }
183
+ catch (e) {
184
+ // Don't fail the whole request — Cloudflare 403s during goto can
185
+ // happen for events that return 404 on the web shell but accept
186
+ // the API POST. Surface the error and keep going.
187
+ opts.log(`[luma-bridge] warmup goto failed (${e?.message || e}); proceeding to fetch.`);
188
+ }
189
+ // Settle: let the page's JS finish (Cloudflare token refresh, etc.)
190
+ await page.waitForTimeout(PAGE_SETTLE_MS);
191
+ // Fire the request from INSIDE the page so JA3 + headers + cookies
192
+ // are all browser-native. We pass init through page.evaluate so we
193
+ // don't have to maintain a parallel header set in this module.
194
+ const init = {
195
+ url: req.url,
196
+ method,
197
+ headers: req.headers,
198
+ body: typeof req.body === "string" ? req.body : (req.body ? JSON.stringify(req.body) : undefined),
199
+ };
200
+ const result = await page.evaluate(async (init) => {
201
+ const fetchInit = {
202
+ method: init.method,
203
+ headers: init.headers,
204
+ credentials: "include",
205
+ };
206
+ if (init.body !== undefined)
207
+ fetchInit.body = init.body;
208
+ const r = await fetch(init.url, fetchInit);
209
+ const text = await r.text();
210
+ return { status: r.status, body: text };
211
+ }, init);
212
+ return { ok: true, status: result.status, body: result.body };
213
+ }
214
+ catch (e) {
215
+ return { ok: false, status: 0, body: "", error: `bridge_error: ${e?.message || e}` };
216
+ }
217
+ finally {
218
+ if (ctx) {
219
+ try {
220
+ await ctx.close();
221
+ }
222
+ catch { /* ignore */ }
223
+ }
224
+ }
225
+ }
226
+ // ── HTTP server ──────────────────────────────────────────────────────────
227
+ const server = createServer(async (req, res) => {
228
+ res.setHeader("X-Frame-Options", "DENY");
229
+ res.setHeader("Cache-Control", "no-store");
230
+ if (req.method !== "POST") {
231
+ res.statusCode = 405;
232
+ res.end(JSON.stringify({ ok: false, error: "method_not_allowed" }));
233
+ return;
234
+ }
235
+ // Auth — bearer token must match what we minted. Reject any request
236
+ // missing or mismatched. Drop in constant time isn't critical for
237
+ // local-only IPC but cheap to do anyway.
238
+ const auth = String(req.headers["authorization"] || "");
239
+ const expected = `Bearer ${token}`;
240
+ if (auth.length !== expected.length || auth !== expected) {
241
+ res.statusCode = 401;
242
+ res.end(JSON.stringify({ ok: false, error: "unauthorized" }));
243
+ return;
244
+ }
245
+ if (req.url !== "/luma/forward") {
246
+ res.statusCode = 404;
247
+ res.end(JSON.stringify({ ok: false, error: "not_found" }));
248
+ return;
249
+ }
250
+ // Read JSON body with a hard size cap.
251
+ const chunks = [];
252
+ let total = 0;
253
+ let oversized = false;
254
+ for await (const chunk of req) {
255
+ total += chunk.length;
256
+ if (total > MAX_BODY_BYTES) {
257
+ oversized = true;
258
+ break;
259
+ }
260
+ chunks.push(chunk);
261
+ }
262
+ if (oversized) {
263
+ res.statusCode = 413;
264
+ res.end(JSON.stringify({ ok: false, error: "payload_too_large" }));
265
+ return;
266
+ }
267
+ let payload;
268
+ try {
269
+ payload = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
270
+ }
271
+ catch {
272
+ res.statusCode = 400;
273
+ res.end(JSON.stringify({ ok: false, error: "invalid_json" }));
274
+ return;
275
+ }
276
+ if (!payload || typeof payload !== "object" || !payload.url) {
277
+ res.statusCode = 400;
278
+ res.end(JSON.stringify({ ok: false, error: "missing_url" }));
279
+ return;
280
+ }
281
+ const out = await _forward(payload);
282
+ res.statusCode = 200;
283
+ res.setHeader("Content-Type", "application/json");
284
+ res.end(JSON.stringify(out));
285
+ });
286
+ // Bind to 127.0.0.1 with port 0 — OS picks a free port. Promise resolves
287
+ // after listen() so we can return the URL.
288
+ await new Promise((resolve, reject) => {
289
+ server.once("error", reject);
290
+ server.listen(0, "127.0.0.1", () => resolve());
291
+ });
292
+ const addr = server.address();
293
+ if (!addr || typeof addr === "string") {
294
+ server.close();
295
+ return null;
296
+ }
297
+ // Read the state mtime upfront so invalidateState() has a baseline.
298
+ try {
299
+ const stat = await fs.stat(statePath);
300
+ stateMtime = stat.mtimeMs;
301
+ }
302
+ catch { /* ignore */ }
303
+ opts.log(`Luma browser bridge ready on http://127.0.0.1:${addr.port} (storage state from ${new Date(stateMtime).toISOString()}).`);
304
+ return {
305
+ url: `http://127.0.0.1:${addr.port}`,
306
+ token,
307
+ shutdown: async () => {
308
+ try {
309
+ server.close();
310
+ }
311
+ catch { /* ignore */ }
312
+ if (browser) {
313
+ try {
314
+ await browser.close();
315
+ }
316
+ catch { /* ignore */ }
317
+ }
318
+ },
319
+ invalidateState: () => {
320
+ // Force a fresh context on the next call. We don't tear down the
321
+ // browser itself — just the cached state mtime so the next read
322
+ // re-loads from disk.
323
+ stateMtime = 0;
324
+ },
325
+ };
326
+ }
@@ -27,6 +27,7 @@ export interface LumaLoginResult {
27
27
  auth_session_key: string;
28
28
  user_id?: string;
29
29
  };
30
+ extraCookies?: Record<string, string>;
30
31
  profileName?: string;
31
32
  }
32
33
  export interface LumaLoginError {
package/dist/lumaLogin.js CHANGED
@@ -133,14 +133,39 @@ export async function runLumaLoginFlow(progress) {
133
133
  if (!succeeded) {
134
134
  return { ok: false, error: "login_timeout", detail: "5-minute sign-in window expired." };
135
135
  }
136
- // Cookie names captured 2026-05-01 from luma.com after signin:
137
- // luma.auth-session-key — required for every api2.luma.com call
138
- // luma.user-id — informational, lets tools short-circuit /me
139
- // Read both luma.com and api2.luma.com cookies — the session is shared
140
- // across both subdomains via the registrable parent.
136
+ // Cookie names we replay on every api2.luma.com call:
137
+ // luma.auth-session-key — load-bearing session token (required)
138
+ // luma.user-id — informational; short-circuits /me lookups
139
+ // cf_clearance — Cloudflare bot-challenge clearance token.
140
+ // Issued only after a Turnstile / managed
141
+ // challenge passes. Reads work without it
142
+ // (served from CF edge cache); WRITES like
143
+ // POST /event/register fail with 403 when
144
+ // it's absent — see run cd0d03d05e684e0f.
145
+ // __cf_bm — Cloudflare bot-management cookie. Rotates
146
+ // every ~30min; not strictly required but
147
+ // having it reduces the false-positive rate
148
+ // on the bot heuristic.
149
+ // _cfuvid — Cloudflare unique-visitor id; same role.
150
+ // Cookies are scoped to luma.com / lu.ma / api.luma.com — Playwright's
151
+ // context.cookies() returns them all; we filter by name, not domain.
141
152
  const allCookies = await context.cookies();
142
153
  const auth_c = allCookies.find((c) => c.name === "luma.auth-session-key");
143
154
  const uid_c = allCookies.find((c) => c.name === "luma.user-id");
155
+ const CLOUDFLARE_COOKIE_NAMES = ["cf_clearance", "__cf_bm", "_cfuvid"];
156
+ const extraCookies = {};
157
+ for (const name of CLOUDFLARE_COOKIE_NAMES) {
158
+ // Pick the most-recently-set value (Playwright orders by insertion
159
+ // but a single name can have multiple entries across subdomains).
160
+ // The api2.luma.com one takes priority since that's the request
161
+ // target; fall back to luma.com if api isn't set yet.
162
+ const matches = allCookies.filter((c) => c.name === name);
163
+ const apiCookie = matches.find((c) => c.domain.includes("api.luma.com") || c.domain.includes("api2.luma.com"));
164
+ const webCookie = matches.find((c) => c.domain.endsWith("luma.com") || c.domain.endsWith("lu.ma"));
165
+ const picked = apiCookie?.value || webCookie?.value || matches[0]?.value;
166
+ if (picked)
167
+ extraCookies[name] = picked;
168
+ }
144
169
  if (!auth_c?.value) {
145
170
  return {
146
171
  ok: false, error: "cookies_not_found",
@@ -161,12 +186,37 @@ export async function runLumaLoginFlow(progress) {
161
186
  });
162
187
  }
163
188
  catch { /* swallow */ }
164
- progress(profileName ? `Captured session for ${profileName}.` : "Captured session.");
189
+ const cfNames = Object.keys(extraCookies);
190
+ const cfSummary = cfNames.length > 0 ? ` + ${cfNames.length} Cloudflare cookie${cfNames.length > 1 ? "s" : ""} (${cfNames.join(", ")})` : "";
191
+ // Persist the FULL Playwright storage state so the runner's browser
192
+ // bridge (lumaBrowserBridge.ts) can replay the same session for
193
+ // bot-rule-gated POSTs like /event/register. Cookies alone aren't
194
+ // enough — Cloudflare also fingerprints cf_bm freshness, JS
195
+ // execution, headers, and TLS — all of which the bridge gets for
196
+ // free by reusing this state inside a real Chromium context.
197
+ let bridgeStateNote = "";
198
+ try {
199
+ const path = await import("node:path");
200
+ const os = await import("node:os");
201
+ const fs = await import("node:fs/promises");
202
+ const stateDir = path.join(os.homedir(), ".melaya", "luma");
203
+ const statePath = path.join(stateDir, "storage-state.json");
204
+ await fs.mkdir(stateDir, { recursive: true });
205
+ // Playwright's storageState() returns cookies + localStorage +
206
+ // sessionStorage + indexedDB-flavored entries. We persist as-is.
207
+ await context.storageState({ path: statePath });
208
+ bridgeStateNote = " · browser session saved for bot-rule bypass";
209
+ }
210
+ catch (e) {
211
+ bridgeStateNote = ` · WARN: could not save browser session (${e?.message || e})`;
212
+ }
213
+ progress((profileName ? `Captured session for ${profileName}` : "Captured session") + cfSummary + bridgeStateNote + ".");
165
214
  await context.close();
166
215
  await browser.close();
167
216
  return {
168
217
  ok: true,
169
218
  cookies: { auth_session_key: auth_c.value, user_id: uid_c?.value },
219
+ extraCookies: Object.keys(extraCookies).length > 0 ? extraCookies : undefined,
170
220
  profileName,
171
221
  };
172
222
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.0.21",
3
+ "version": "1.0.24",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,