pi-harness-runtime 0.3.2-beta.2 → 0.4.0

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.
@@ -1,19 +1,34 @@
1
1
  /**
2
2
  * minimax-browser-auth.ts
3
3
  *
4
- * MiniMax browser authentication using persistent Playwright profile.
4
+ * MiniMax browser authentication using a real Chrome profile.
5
5
  *
6
6
  * SECURITY RULES:
7
7
  * - Human owns authentication. Agent never receives credentials.
8
8
  * - No username, password, raw cookies, or session tokens stored.
9
- * - Playwright persistent profile saves browser session automatically.
9
+ * - Real Chrome owns the login flow via a persistent profile.
10
10
  * - Profile is stored at ~/.pi-harness-runtime/browser-profiles/minimax/
11
11
  */
12
12
 
13
- import * as fs from "fs";
14
- import * as path from "path";
15
- import * as os from "os";
16
- import { chromium, type BrowserContext } from "playwright";
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
+ import * as os from "node:os";
16
+ import * as net from "node:net";
17
+ import { fileURLToPath } from "node:url";
18
+ import { createRequire } from "node:module";
19
+ import { execFileSync, spawn, type ChildProcess } from "node:child_process";
20
+ import { createInterface } from "node:readline/promises";
21
+ import {
22
+ chromium,
23
+ type Browser,
24
+ type BrowserContext,
25
+ type Page,
26
+ } from "playwright";
27
+
28
+ // ESM __dirname shim (not available in ESM without this)
29
+ const __filename = fileURLToPath(import.meta.url);
30
+ const __dirname = path.dirname(__filename);
31
+ const _require = createRequire(import.meta.url);
17
32
 
18
33
  export interface MinimaxAuthStatus {
19
34
  provider: "minimax";
@@ -22,6 +37,7 @@ export interface MinimaxAuthStatus {
22
37
  page_url: string;
23
38
  detected_text_sample: string | null;
24
39
  profile_path: string;
40
+ usage_lines?: string[];
25
41
  error_message?: string;
26
42
  }
27
43
 
@@ -29,6 +45,12 @@ export interface MinimaxBrowserAuthConfig {
29
45
  profilePath?: string;
30
46
  statusPath?: string;
31
47
  targetUrl?: string;
48
+ chromeExecutablePath?: string;
49
+ authTimeoutMs?: number;
50
+ headless?: boolean;
51
+ cdpPort?: number;
52
+ /** Override live session detection — used by tests to bypass real daemon. */
53
+ forceNoLiveSession?: boolean;
32
54
  }
33
55
 
34
56
  const DEFAULT_USAGE_KEYWORDS = [
@@ -58,6 +80,19 @@ export function getStatusPath(): string {
58
80
  return path.join(getRuntimeDir(), "auth", "minimax-auth-status.json");
59
81
  }
60
82
 
83
+ export interface MinimaxLiveBrowserSession {
84
+ profile_path: string;
85
+ target_url: string;
86
+ chrome_path: string;
87
+ debugging_port: number;
88
+ pid: number;
89
+ started_at: string;
90
+ }
91
+
92
+ export function getLiveSessionPath(): string {
93
+ return path.join(getRuntimeDir(), "auth", "minimax-live-browser.json");
94
+ }
95
+
61
96
  function ensureDirs(config: MinimaxBrowserAuthConfig): void {
62
97
  const profileDir = config.profilePath ?? getProfileDir();
63
98
  const statusPath = config.statusPath ?? getStatusPath();
@@ -74,6 +109,52 @@ export function saveAuthStatus(
74
109
  fs.writeFileSync(statusPath, JSON.stringify(status, null, 2));
75
110
  }
76
111
 
112
+ function loadSavedAuthStatus(
113
+ config?: MinimaxBrowserAuthConfig,
114
+ ): MinimaxAuthStatus | null {
115
+ const statusPath = config?.statusPath ?? getStatusPath();
116
+ if (!fs.existsSync(statusPath)) {
117
+ return null;
118
+ }
119
+ try {
120
+ return JSON.parse(
121
+ fs.readFileSync(statusPath, "utf-8"),
122
+ ) as MinimaxAuthStatus;
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ function saveLiveBrowserSession(
129
+ session: MinimaxLiveBrowserSession,
130
+ liveSessionPath?: string,
131
+ ): void {
132
+ const p = liveSessionPath ?? getLiveSessionPath();
133
+ fs.mkdirSync(path.dirname(p), { recursive: true });
134
+ fs.writeFileSync(p, JSON.stringify(session, null, 2));
135
+ }
136
+
137
+ export function loadLiveBrowserSession(
138
+ liveSessionPath?: string,
139
+ ): MinimaxLiveBrowserSession | null {
140
+ const p = liveSessionPath ?? getLiveSessionPath();
141
+ if (!fs.existsSync(p)) {
142
+ return null;
143
+ }
144
+ try {
145
+ return JSON.parse(fs.readFileSync(p, "utf-8")) as MinimaxLiveBrowserSession;
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ function clearLiveBrowserSession(liveSessionPath?: string): void {
152
+ const p = liveSessionPath ?? getLiveSessionPath();
153
+ if (fs.existsSync(p)) {
154
+ fs.rmSync(p, { force: true });
155
+ }
156
+ }
157
+
77
158
  export function detectUsagePage(bodyText: string): {
78
159
  detected: boolean;
79
160
  sample: string | null;
@@ -88,18 +169,222 @@ export function detectUsagePage(bodyText: string): {
88
169
  return { detected, sample };
89
170
  }
90
171
 
172
+ const DEFAULT_AUTH_TIMEOUT_MS = 300000;
173
+
174
+ function delay(ms: number): Promise<void> {
175
+ return new Promise((resolve) => setTimeout(resolve, ms));
176
+ }
177
+
178
+ function hasProfileData(profileDir: string): boolean {
179
+ if (!fs.existsSync(profileDir)) {
180
+ return false;
181
+ }
182
+ const markers = [
183
+ path.join(profileDir, "Local State"),
184
+ path.join(profileDir, "First Run"),
185
+ path.join(profileDir, "Default", "Preferences"),
186
+ path.join(profileDir, "Default", "Cookies"),
187
+ path.join(profileDir, "Default", "History"),
188
+ ];
189
+ if (markers.some((marker) => fs.existsSync(marker))) {
190
+ return true;
191
+ }
192
+ try {
193
+ return fs.readdirSync(profileDir).length > 0;
194
+ } catch {
195
+ return false;
196
+ }
197
+ }
198
+
199
+ function getChromeExecutablePath(
200
+ config: MinimaxBrowserAuthConfig = {},
201
+ ): string {
202
+ const configuredPath =
203
+ config.chromeExecutablePath ??
204
+ process.env.PI_HARNESS_CHROME_PATH ??
205
+ process.env.GOOGLE_CHROME_BIN ??
206
+ process.env.CHROME_PATH;
207
+ const candidates = configuredPath ? [configuredPath] : [];
208
+ switch (process.platform) {
209
+ case "darwin":
210
+ candidates.push(
211
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
212
+ "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
213
+ );
214
+ break;
215
+ case "win32":
216
+ candidates.push(
217
+ "C:Program FilesGoogleChromeApplicationchrome.exe",
218
+ "C:Program Files (x86)GoogleChromeApplicationchrome.exe",
219
+ path.join(
220
+ process.env.LOCALAPPDATA ?? "",
221
+ "Google",
222
+ "Chrome",
223
+ "Application",
224
+ "chrome.exe",
225
+ ),
226
+ );
227
+ break;
228
+ default:
229
+ candidates.push(
230
+ "/usr/bin/google-chrome",
231
+ "/usr/bin/google-chrome-stable",
232
+ "/snap/bin/google-chrome",
233
+ );
234
+ }
235
+ for (const candidate of candidates) {
236
+ if (candidate && fs.existsSync(candidate)) {
237
+ return candidate;
238
+ }
239
+ }
240
+ throw new Error(
241
+ "Google Chrome executable not found. Install Google Chrome or set PI_HARNESS_CHROME_PATH.",
242
+ );
243
+ }
244
+
245
+ function getChromeArgs(): string[] {
246
+ const args = ["--no-first-run", "--no-default-browser-check"];
247
+ if (process.platform === "linux") {
248
+ args.push("--password-store=basic");
249
+ }
250
+ return args;
251
+ }
252
+
253
+ function getAuthTimeoutMs(config: MinimaxBrowserAuthConfig): number {
254
+ const configuredTimeout =
255
+ config.authTimeoutMs ??
256
+ (process.env.PI_HARNESS_AUTH_TIMEOUT_MS
257
+ ? Number(process.env.PI_HARNESS_AUTH_TIMEOUT_MS)
258
+ : undefined);
259
+ if (
260
+ typeof configuredTimeout === "number" &&
261
+ Number.isFinite(configuredTimeout) &&
262
+ configuredTimeout > 0
263
+ ) {
264
+ return configuredTimeout;
265
+ }
266
+ return DEFAULT_AUTH_TIMEOUT_MS;
267
+ }
268
+
269
+ async function getFreePort(preferredPort?: number): Promise<number> {
270
+ if (preferredPort) return preferredPort;
271
+ return new Promise((resolve, reject) => {
272
+ const server = net.createServer();
273
+ server.unref();
274
+ server.on("error", reject);
275
+ server.listen(0, "127.0.0.1", () => {
276
+ const address = server.address();
277
+ if (!address || typeof address === "string") {
278
+ server.close();
279
+ reject(new Error("Could not allocate a Chrome debugging port"));
280
+ return;
281
+ }
282
+ const { port } = address;
283
+ server.close((closeError) => {
284
+ if (closeError) {
285
+ reject(closeError);
286
+ return;
287
+ }
288
+ resolve(port);
289
+ });
290
+ });
291
+ });
292
+ }
293
+
294
+ async function connectToChromeOverCdp(
295
+ port: number,
296
+ timeoutMs = 15000,
297
+ ): Promise<Browser> {
298
+ const endpoint = `http://127.0.0.1:${port}`;
299
+ const deadline = Date.now() + timeoutMs;
300
+ let lastError: unknown;
301
+ while (Date.now() < deadline) {
302
+ try {
303
+ return await chromium.connectOverCDP(endpoint);
304
+ } catch (error) {
305
+ lastError = error;
306
+ await delay(500);
307
+ }
308
+ }
309
+ throw new Error(
310
+ `Could not connect to Chrome DevTools at ${endpoint}: ${String(lastError)}`,
311
+ );
312
+ }
313
+
314
+ function getCurrentPage(context: BrowserContext): Page | null {
315
+ const pages = context.pages().filter((p) => !p.isClosed());
316
+ return pages.length > 0 ? pages[pages.length - 1] : null;
317
+ }
318
+
319
+ async function waitForPage(
320
+ context: BrowserContext,
321
+ timeoutMs = 15000,
322
+ ): Promise<Page> {
323
+ const deadline = Date.now() + timeoutMs;
324
+ while (Date.now() < deadline) {
325
+ const page = getCurrentPage(context);
326
+ if (page) return page;
327
+ await delay(250);
328
+ }
329
+ throw new Error("Chrome launched but no page became available");
330
+ }
331
+
332
+ async function closeBrowserResources(
333
+ browser: Browser | null,
334
+ chromeProcess: ChildProcess | null,
335
+ ): Promise<void> {
336
+ if (browser) await browser.close().catch(() => {});
337
+ if (
338
+ chromeProcess &&
339
+ chromeProcess.exitCode === null &&
340
+ !chromeProcess.killed
341
+ ) {
342
+ chromeProcess.kill("SIGTERM");
343
+ }
344
+ }
345
+
346
+ async function launchChromeForAuthentication(
347
+ profileDir: string,
348
+ targetUrl: string,
349
+ config: MinimaxBrowserAuthConfig,
350
+ ): Promise<{
351
+ browser: Awaited<ReturnType<typeof connectToChromeOverCdp>>;
352
+ chromeProcess: ChildProcess;
353
+ chromePath: string;
354
+ cdpPort: number;
355
+ }> {
356
+ const chromePath = getChromeExecutablePath(config);
357
+ const cdpPort = await getFreePort(config.cdpPort);
358
+ const chromeArgs = [
359
+ `--remote-debugging-port=${cdpPort}`,
360
+ `--user-data-dir=${profileDir}`,
361
+ "--new-window",
362
+ ...getChromeArgs(),
363
+ targetUrl,
364
+ ];
365
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
366
+ const chromeProcess = spawn(chromePath, chromeArgs, { stdio: "ignore" });
367
+ const browser = await connectToChromeOverCdp(cdpPort);
368
+ const context = browser.contexts()[0] as BrowserContext;
369
+ if (!context) {
370
+ await closeBrowserResources(browser, chromeProcess);
371
+ throw new Error(
372
+ "Connected to Chrome, but no browser context was available",
373
+ );
374
+ }
375
+ return { browser, chromeProcess, chromePath, cdpPort };
376
+ }
377
+
91
378
  /**
92
- * Launch persistent browser - profile is saved automatically
93
- * Reusing the profile means user stays logged in
379
+ * Manual authentication flow: launches real Chrome so Google login works,
380
+ * waits for user to sign in, then confirms at the TTY.
94
381
  */
95
382
  export async function authenticateWithPersistentBrowser(
96
383
  config: MinimaxBrowserAuthConfig = {},
97
384
  ): Promise<MinimaxAuthStatus> {
98
385
  const profileDir = config.profilePath ?? getProfileDir();
99
- const statusPath = config.statusPath ?? getStatusPath();
100
386
  const targetUrl =
101
387
  config.targetUrl ?? "https://platform.minimax.io/console/usage";
102
-
103
388
  ensureDirs(config);
104
389
 
105
390
  console.log("=".repeat(60));
@@ -109,127 +394,146 @@ export async function authenticateWithPersistentBrowser(
109
394
  console.log("SECURITY: Human owns authentication.");
110
395
  console.log("- Login once, profile is saved for reuse");
111
396
  console.log("");
112
- console.log(`Profile directory: ${profileDir}`);
397
+ console.log("Profile directory: " + profileDir);
113
398
  console.log("");
114
399
 
115
- let context: BrowserContext | null = null;
400
+ let browser: Browser | null = null;
401
+ let chromeProcess: ChildProcess | null = null;
116
402
 
117
403
  try {
118
- // Check if profile exists (user already logged in)
119
- const isFirstRun = !fs.existsSync(path.join(profileDir, "SingletonLock"));
120
-
121
- // Launch persistent context - profile auto-saves!
122
- console.log("🚀 Launching persistent browser...");
404
+ const isFirstRun = !hasProfileData(profileDir);
405
+ console.log("🚀 Launching real Google Chrome...");
123
406
  if (isFirstRun) {
124
- console.log(" First run - creating new profile");
407
+ console.log(" First run - creating new Chrome profile");
125
408
  } else {
126
- console.log(" Reusing existing profile (you stay logged in)");
409
+ console.log(" Reusing existing Chrome profile");
127
410
  }
128
- console.log("");
129
411
 
130
- context = await chromium.launchPersistentContext(profileDir, {
131
- headless: false,
132
- chromiumSandbox: false,
133
- args: ["--no-first-run", "--no-default-browser-check"],
134
- viewport: { width: 1280, height: 800 },
135
- });
412
+ const launched = await launchChromeForAuthentication(
413
+ profileDir,
414
+ targetUrl,
415
+ config,
416
+ );
417
+ browser = launched.browser;
418
+ chromeProcess = launched.chromeProcess;
419
+ const context = browser.contexts()[0];
420
+ console.log(" Chrome: " + launched.chromePath);
421
+ console.log(" DevTools port: " + launched.cdpPort);
422
+ console.log("");
423
+ console.log("🖥️ Waiting for MiniMax / Google login flow...");
136
424
 
137
- const page = context.pages()[0] ?? (await context.newPage());
425
+ let page = await waitForPage(context, 15000);
426
+ await page
427
+ .waitForLoadState("domcontentloaded", { timeout: 15000 })
428
+ .catch(() => {});
138
429
 
139
- // Navigate to MiniMax
140
- console.log("🖥️ Navigating to MiniMax...");
141
- await page.goto(targetUrl, {
142
- waitUntil: "domcontentloaded",
143
- timeout: 15000,
144
- });
145
- await page.waitForTimeout(2000);
430
+ let loginComplete = false;
431
+ let lastLoggedUrl = "";
432
+ let loginHintShown = false;
433
+ const startTime = Date.now();
434
+ const authTimeoutMs = getAuthTimeoutMs(config);
146
435
 
147
- const initialUrl = page.url();
148
- console.log(` Current URL: ${initialUrl}`);
149
- console.log("");
436
+ while (Date.now() - startTime < authTimeoutMs) {
437
+ page = getCurrentPage(context) ?? page;
438
+ if (!page || page.isClosed()) {
439
+ console.log("Browser closed by user.");
440
+ const status: MinimaxAuthStatus = {
441
+ provider: "minimax",
442
+ authenticated: false,
443
+ checked_at: new Date().toISOString(),
444
+ page_url: "",
445
+ detected_text_sample: null,
446
+ profile_path: profileDir,
447
+ error_message: "Browser closed by user",
448
+ };
449
+ saveAuthStatus(status, config);
450
+ return status;
451
+ }
150
452
 
151
- // If redirected to login, wait for user to log in
152
- if (initialUrl.includes("login") || initialUrl.includes("unified-login")) {
153
- console.log(
154
- "🔓 Login required - please log in to MiniMax in the browser window",
155
- );
156
- console.log(" (Profile will auto-save when you navigate)");
157
- console.log("");
158
- console.log("⏳ Waiting for you to log in and navigate to usage page...");
159
- console.log(" Press Enter when done, or wait 60 seconds...");
160
- console.log("");
453
+ const currentUrl = page.url();
454
+ if (currentUrl && currentUrl !== lastLoggedUrl) {
455
+ console.log(" 🔗 URL: " + currentUrl);
456
+ lastLoggedUrl = currentUrl;
457
+ }
161
458
 
162
- // Wait for user input or timeout
163
- await Promise.race([
164
- new Promise<void>((resolve) => {
165
- process.stdin.once("data", () => {
166
- console.log("✅ Resuming...");
167
- resolve();
168
- });
169
- }),
170
- new Promise<void>((resolve) =>
171
- setTimeout(() => {
172
- console.log("⏰ Timeout - checking current state...");
173
- resolve();
174
- }, 60000),
175
- ),
176
- ]);
177
- }
459
+ const isGoogleLogin = currentUrl.includes("accounts.google.com");
460
+ const isMiniMaxLogin =
461
+ currentUrl.includes("unified-login") || currentUrl.includes("login");
462
+ if ((isGoogleLogin || isMiniMaxLogin) && !loginHintShown) {
463
+ console.log("");
464
+ console.log("🔓 Sign in in the Chrome window that just opened");
465
+ console.log(" This uses real Chrome so Google login is allowed");
466
+ console.log(" Close browser or press Ctrl+C to cancel");
467
+ console.log("");
468
+ loginHintShown = true;
469
+ }
178
470
 
179
- // Extract usage data
180
- console.log("");
181
- console.log("📊 Extracting usage data...");
471
+ const bodyText = (await page.textContent("body").catch(() => "")) ?? "";
472
+ const { detected } = detectUsagePage(bodyText);
473
+ if (detected) {
474
+ loginComplete = true;
475
+ break;
476
+ }
182
477
 
183
- await page.goto(targetUrl, { waitUntil: "networkidle", timeout: 30000 });
184
- await page.waitForTimeout(2000);
478
+ await delay(1500);
479
+ }
185
480
 
186
- const url = page.url();
187
- const bodyText = (await page.textContent("body")) ?? "";
188
- const { detected, sample } = detectUsagePage(bodyText);
481
+ if (!loginComplete) {
482
+ console.log("");
483
+ console.log("⏰ Browser stayed open after timeout. Please close it.");
484
+ console.log("");
485
+ await waitForChromeToExit(chromeProcess);
486
+ const status: MinimaxAuthStatus = {
487
+ provider: "minimax",
488
+ authenticated: false,
489
+ checked_at: new Date().toISOString(),
490
+ page_url: "",
491
+ detected_text_sample: null,
492
+ profile_path: profileDir,
493
+ error_message: "Login not completed within timeout",
494
+ };
495
+ saveAuthStatus(status, config);
496
+ browser = null;
497
+ chromeProcess = null;
498
+ return status;
499
+ }
189
500
 
501
+ // Usage page detected — wait for human to close the browser
190
502
  console.log("");
191
- console.log(` URL: ${url}`);
192
- console.log(` Detected usage page: ${detected}`);
193
- if (sample) {
194
- console.log(` Sample: ${sample.substring(0, 100)}...`);
195
- }
503
+ console.log("✅ Usage page reached!");
504
+ console.log("💾 Profile is being saved automatically.");
196
505
  console.log("");
197
-
198
- // Profile is auto-saved by Playwright - no manual save needed
199
- console.log("💾 Profile auto-saved to:");
200
- console.log(` ${profileDir}`);
506
+ console.log("⚠️ Please close the Chrome window now.");
201
507
  console.log("");
508
+ await waitForChromeToExit(chromeProcess);
202
509
 
510
+ const confirmed = await confirmUsagePageReached(30000);
203
511
  const status: MinimaxAuthStatus = {
204
512
  provider: "minimax",
205
- authenticated: detected,
513
+ authenticated: confirmed,
206
514
  checked_at: new Date().toISOString(),
207
- page_url: url,
208
- detected_text_sample: sample,
515
+ page_url: targetUrl,
516
+ detected_text_sample: confirmed ? "Usage page confirmed by user" : null,
209
517
  profile_path: profileDir,
210
518
  };
211
-
212
519
  saveAuthStatus(status, config);
213
520
 
214
- if (detected) {
215
- console.log("✅ Authentication successful!");
521
+ console.log("");
522
+ if (confirmed) {
523
+ console.log(
524
+ "✅ Authentication saved! Future scrapes will use this profile.",
525
+ );
216
526
  } else {
217
- console.log("⚠️ Could not verify usage page. Try again after login.");
527
+ console.log("⚠️ Auth not confirmed. Run 'auth' again after login.");
218
528
  }
219
529
 
220
- // Close browser
221
- await context.close();
222
- context = null;
223
-
530
+ browser = null;
531
+ chromeProcess = null;
224
532
  return status;
225
533
  } catch (error) {
226
534
  const errorMessage = error instanceof Error ? error.message : String(error);
227
- console.error(`❌ Error: ${errorMessage}`);
228
-
229
- if (context) {
230
- await context.close().catch(() => {});
231
- }
232
-
535
+ console.error("❌ Error: " + errorMessage);
536
+ await closeBrowserResources(browser, chromeProcess);
233
537
  const status: MinimaxAuthStatus = {
234
538
  provider: "minimax",
235
539
  authenticated: false,
@@ -239,32 +543,394 @@ export async function authenticateWithPersistentBrowser(
239
543
  profile_path: profileDir,
240
544
  error_message: errorMessage,
241
545
  };
546
+ saveAuthStatus(status, config);
547
+ return status;
548
+ }
549
+ }
550
+
551
+ async function waitForChromeToExit(chromeProcess: ChildProcess): Promise<void> {
552
+ return new Promise((resolve) => {
553
+ // macOS: helper processes keep chrome running even after window closes
554
+ // Use process group to kill all related processes
555
+ const timeout = setTimeout(() => {
556
+ console.log(
557
+ " (timeout waiting for Chrome exit — killing process group)",
558
+ );
559
+ try {
560
+ process.kill(chromeProcess.pid!, "SIGTERM");
561
+ } catch (_e) {
562
+ /* ignore */
563
+ }
564
+ resolve();
565
+ }, 15000);
566
+
567
+ chromeProcess.once("exit", () => {
568
+ clearTimeout(timeout);
569
+ resolve();
570
+ });
571
+
572
+ // Also resolve on Enter (TTY fallback)
573
+ if (process.stdin.isTTY) {
574
+ console.log(" Press ENTER after closing Chrome...");
575
+ process.stdin.once("data", () => {
576
+ clearTimeout(timeout);
577
+ resolve();
578
+ });
579
+ }
580
+ });
581
+ }
582
+
583
+ async function confirmUsagePageReached(_timeoutMs = 30000): Promise<boolean> {
584
+ if (!process.stdin.isTTY) {
585
+ console.log("(non-TTY: auto-confirming)");
586
+ return true;
587
+ }
588
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
589
+ const answer = await rl.question(
590
+ "✅ Did you reach the MiniMax usage page? (y/N): ",
591
+ );
592
+ rl.close();
593
+ return answer.trim().toLowerCase() === "y";
594
+ }
595
+
596
+ /**
597
+ * Extract usage-relevant lines from page body text.
598
+ * Splits on newlines AND HTML tag boundaries, then filters for keywords.
599
+ */
600
+ export function extractUsageLines(bodyText: string): string[] {
601
+ // Strip <script> JSON noise first
602
+ let clean = bodyText.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "");
603
+ clean = clean.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "");
604
+ clean = clean.replace(/\{\s*"[^{}]*\}/g, "");
605
+
606
+ // Split on both newlines and HTML tag boundaries
607
+ const htmlTagRe =
608
+ /\n|<div[^>]*>|<\/div>|<span[^>]*>|<\/span>|<p[^>]*>|<\/p>|<br\s*\/?>|\s{2,}/gi;
609
+ // Replace tag matches with spaces so adjacent text doesn't concatenate
610
+ clean = clean.replace(htmlTagRe, " ");
611
+ const parts = clean.split(/\s+/);
242
612
 
613
+ const lines: string[] = [];
614
+ const keywords = DEFAULT_USAGE_KEYWORDS.map((k) => k.toLowerCase());
615
+
616
+ for (const part of parts) {
617
+ const trimmed = part.trim();
618
+ if (trimmed.length < 3 || trimmed.length > 200) continue;
619
+ const lower = trimmed.toLowerCase();
620
+ const hasKeyword = keywords.some((k) => lower.includes(k));
621
+ const hasNumber = /\d/.test(trimmed);
622
+ const hasPercent = /%|\d+\/\d+/.test(trimmed);
623
+ if (hasKeyword || (hasNumber && hasPercent)) {
624
+ // Deduplicate
625
+ const normalized = trimmed.replace(/\s+/g, " ");
626
+ if (!lines.includes(normalized)) {
627
+ lines.push(normalized);
628
+ }
629
+ }
630
+ }
631
+
632
+ return lines;
633
+ }
634
+
635
+ /**
636
+ * Scrape usage data by attaching to a live browser session (via Node CDP).
637
+ * Writes attach script to a temp .js file to avoid shell escaping issues.
638
+ */
639
+ export async function scrapeViaLiveBrowserSession(
640
+ config: MinimaxBrowserAuthConfig = {},
641
+ ): Promise<MinimaxAuthStatus> {
642
+ const liveSession = loadLiveBrowserSession();
643
+ if (!liveSession) {
644
+ return {
645
+ provider: "minimax",
646
+ authenticated: false,
647
+ checked_at: new Date().toISOString(),
648
+ page_url: "",
649
+ detected_text_sample: null,
650
+ profile_path: config.profilePath ?? getProfileDir(),
651
+ error_message: "No live browser session. Run 'open' first.",
652
+ };
653
+ }
654
+
655
+ const profileDir = liveSession.profile_path;
656
+ const targetUrl = "https://platform.minimax.io/console/usage";
657
+ const port = liveSession.debugging_port;
658
+
659
+ console.log("Attaching to live Chrome session (port " + port + ")...");
660
+ console.log("Profile: " + profileDir);
661
+
662
+ // Resolve playwright from the project root so Node can find it in the temp script.
663
+ // Walk up from this file's location to find the project root (has node_modules).
664
+ let projectRoot = __dirname;
665
+ while (
666
+ projectRoot !== "/" &&
667
+ projectRoot !== "" &&
668
+ !fs.existsSync(path.join(projectRoot, "node_modules", "playwright"))
669
+ ) {
670
+ projectRoot = path.dirname(projectRoot);
671
+ }
672
+ if (!fs.existsSync(path.join(projectRoot, "node_modules", "playwright"))) {
673
+ // Fallback: try CWD
674
+ projectRoot = process.cwd();
675
+ }
676
+ const playwrightPath = _require.resolve("playwright", {
677
+ paths: [projectRoot],
678
+ });
679
+
680
+ // Build attach script as a plain string (no template literals with regex)
681
+ const scriptLines = [
682
+ "const { chromium } = require('" +
683
+ playwrightPath.replace(/\\/g, "\\\\") +
684
+ "');",
685
+ "(async () => {",
686
+ " const browser = await chromium.connectOverCDP('http://127.0.0.1:" +
687
+ port +
688
+ "');",
689
+ " const context = browser.contexts()[0];",
690
+ " if (!context) { console.error('NO_CONTEXT'); process.exit(1); }",
691
+ " const pages = context.pages().filter(p => !p.isClosed());",
692
+ " const page = pages.length > 0 ? pages[pages.length - 1] : await context.newPage();",
693
+ " await page.goto('" +
694
+ targetUrl +
695
+ "', { waitUntil: 'domcontentloaded', timeout: 30000 });",
696
+ " await page.waitForTimeout(3000);",
697
+ " const url = page.url();",
698
+ " const bodyText = await page.textContent('body').catch(() => '');",
699
+ " const result = JSON.stringify({ url, bodyText });",
700
+ " console.log('RESULT:' + result);",
701
+ " await browser.close();",
702
+ "})().catch(e => { console.error('ERROR:' + e.message); process.exit(1); });",
703
+ ];
704
+ const scriptContent = scriptLines.join("\n");
705
+
706
+ // Write to temp file to avoid shell escaping issues
707
+ const tmpDir = os.tmpdir();
708
+ const scriptPath = path.join(
709
+ tmpDir,
710
+ "pi-harness-minimax-attach-" + Date.now() + ".js",
711
+ );
712
+ fs.writeFileSync(scriptPath, scriptContent);
713
+
714
+ try {
715
+ const raw = execFileSync("node", [scriptPath], {
716
+ encoding: "utf-8",
717
+ timeout: 60000,
718
+ stdio: ["ignore", "pipe", "pipe"],
719
+ }).trim();
720
+
721
+ fs.unlinkSync(scriptPath);
722
+
723
+ const resultMatch = raw.match(/RESULT:(.+)/s);
724
+ if (!resultMatch) {
725
+ throw new Error("No RESULT from attach script: " + raw.substring(0, 200));
726
+ }
727
+
728
+ const parsed = JSON.parse(resultMatch[1]);
729
+ const bodyText: string = parsed.bodyText ?? "";
730
+ const url: string = parsed.url ?? "";
731
+ const { detected, sample } = detectUsagePage(bodyText);
732
+ const usage_lines = extractUsageLines(bodyText);
733
+
734
+ console.log("URL: " + url);
735
+ console.log("Detected usage page: " + detected);
736
+ if (usage_lines.length > 0) {
737
+ console.log("Usage lines (" + usage_lines.length + "):");
738
+ usage_lines.slice(0, 20).forEach((l) => console.log(" " + l));
739
+ }
740
+
741
+ const status: MinimaxAuthStatus = {
742
+ provider: "minimax",
743
+ authenticated: detected,
744
+ checked_at: new Date().toISOString(),
745
+ page_url: url,
746
+ detected_text_sample: sample,
747
+ profile_path: profileDir,
748
+ usage_lines,
749
+ };
243
750
  saveAuthStatus(status, config);
244
751
  return status;
752
+ } catch (error) {
753
+ try {
754
+ fs.unlinkSync(scriptPath);
755
+ } catch (_e) {
756
+ /* ignore */
757
+ }
758
+ const errorMessage = error instanceof Error ? error.message : String(error);
759
+ console.error("Attach failed: " + errorMessage);
760
+ return {
761
+ provider: "minimax",
762
+ authenticated: false,
763
+ checked_at: new Date().toISOString(),
764
+ page_url: "",
765
+ detected_text_sample: null,
766
+ profile_path: profileDir,
767
+ error_message: "Live attach failed: " + errorMessage,
768
+ };
769
+ }
770
+ }
771
+
772
+ /**
773
+ * Start a persistent browser daemon (keep-open mode).
774
+ * Launches real Chrome with --remote-debugging-port and saves session.
775
+ */
776
+ export async function startPersistentBrowserDaemon(
777
+ config: MinimaxBrowserAuthConfig = {},
778
+ ): Promise<MinimaxLiveBrowserSession> {
779
+ const profileDir = config.profilePath ?? getProfileDir();
780
+ const targetUrl =
781
+ config.targetUrl ?? "https://platform.minimax.io/console/usage";
782
+ ensureDirs(config);
783
+
784
+ // Kill any existing daemon for this profile
785
+ await stopPersistentBrowserDaemon(config);
786
+
787
+ const chromePath = getChromeExecutablePath(config);
788
+ const cdpPort = await getFreePort(config.cdpPort ?? 9222);
789
+ const chromeArgs = [
790
+ "--remote-debugging-port=" + cdpPort,
791
+ "--user-data-dir=" + profileDir,
792
+ "--new-window",
793
+ ...getChromeArgs(),
794
+ targetUrl,
795
+ ];
796
+
797
+ console.log("Launching persistent Chrome daemon...");
798
+ console.log(" Chrome: " + chromePath);
799
+ console.log(" Port: " + cdpPort);
800
+ console.log(" Profile: " + profileDir);
801
+
802
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
803
+ const chromeProcess = spawn(chromePath, chromeArgs, {
804
+ stdio: "ignore",
805
+ detached: true,
806
+ });
807
+
808
+ const session: MinimaxLiveBrowserSession = {
809
+ profile_path: profileDir,
810
+ target_url: targetUrl,
811
+ chrome_path: chromePath,
812
+ debugging_port: cdpPort,
813
+ pid: chromeProcess.pid!,
814
+ started_at: new Date().toISOString(),
815
+ };
816
+
817
+ saveLiveBrowserSession(session);
818
+ console.log("Live session saved. PID: " + chromeProcess.pid);
819
+ return session;
820
+ }
821
+
822
+ /**
823
+ * Stop the persistent browser daemon.
824
+ */
825
+ export async function stopPersistentBrowserDaemon(
826
+ config: MinimaxBrowserAuthConfig = {},
827
+ ): Promise<boolean> {
828
+ const liveSession = loadLiveBrowserSession(getLiveSessionPath());
829
+ if (!liveSession) return false;
830
+
831
+ console.log("Stopping live Chrome session (PID " + liveSession.pid + ")...");
832
+ const killed: string[] = [];
833
+
834
+ // Try process group kill
835
+ try {
836
+ process.kill(-liveSession.pid, "SIGTERM");
837
+ killed.push("process group " + liveSession.pid);
838
+ } catch (_e) {
839
+ /* ignore */
840
+ }
841
+
842
+ // Try direct PID
843
+ try {
844
+ process.kill(liveSession.pid, "SIGTERM");
845
+ killed.push("PID " + liveSession.pid);
846
+ } catch (_e) {
847
+ /* ignore */
245
848
  }
849
+
850
+ // Kill by profile path (macOS helper processes)
851
+ try {
852
+ const out = execFileSync(
853
+ "pgrep",
854
+ ["-f", "user-data-dir=" + liveSession.profile_path],
855
+ { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] },
856
+ );
857
+ for (const pid of out.trim().split("\n")) {
858
+ try {
859
+ process.kill(parseInt(pid), "SIGTERM");
860
+ killed.push("profile PID " + pid);
861
+ } catch (_e2) {
862
+ /* ignore */
863
+ }
864
+ }
865
+ } catch (_e) {
866
+ /* ignore */
867
+ }
868
+
869
+ clearLiveBrowserSession();
870
+ console.log(
871
+ "Stopped: " + (killed.length > 0 ? killed.join(", ") : "nothing found"),
872
+ );
873
+ return true;
874
+ }
875
+
876
+ /**
877
+ * Get active live browser session info.
878
+ */
879
+ export function getActiveLiveBrowserSession(): MinimaxLiveBrowserSession | null {
880
+ return loadLiveBrowserSession();
246
881
  }
247
882
 
248
883
  /**
249
- * Use existing profile to scrape usage data (no browser window)
884
+ * Scrape usage data. Tries live session first, falls back to headless Playwright.
250
885
  */
251
886
  export async function scrapeWithExistingProfile(
252
887
  config: MinimaxBrowserAuthConfig = {},
253
888
  ): Promise<MinimaxAuthStatus> {
254
889
  const profileDir = config.profilePath ?? getProfileDir();
255
- const statusPath = config.statusPath ?? getStatusPath();
256
890
  const targetUrl =
257
891
  config.targetUrl ?? "https://platform.minimax.io/console/usage";
258
-
259
892
  ensureDirs(config);
260
893
 
261
894
  console.log("=".repeat(60));
262
- console.log("📊 MiniMax Usage Scraper (Silent Mode)");
895
+ console.log("📊 MiniMax Usage Scraper");
263
896
  console.log("=".repeat(60));
264
897
  console.log("");
265
898
 
266
- // Check if profile exists
267
- const profileExists = fs.existsSync(path.join(profileDir, "SingletonLock"));
899
+ // 1. Try live session first (skip if forced off for testing)
900
+ const liveSession = config.forceNoLiveSession
901
+ ? null
902
+ : loadLiveBrowserSession();
903
+ if (liveSession) {
904
+ console.log("Live browser session found (PID " + liveSession.pid + ")");
905
+ console.log("Trying live attach...");
906
+ const result = await scrapeViaLiveBrowserSession(config);
907
+ if (result.authenticated || result.usage_lines) {
908
+ console.log("✅ Live session scrape successful!");
909
+ return result;
910
+ }
911
+ // Live session exists but attach failed — do NOT fall back to headless
912
+ // because that would try to lock the same profile (already locked by the daemon)
913
+ console.log("⚠️ Live session exists but attach failed.");
914
+ console.log(" Make sure your Chrome window is still open.");
915
+ console.log(
916
+ " To stop and restart: bun packages/auth/src/run-minimax-auth.ts stop",
917
+ );
918
+ return {
919
+ provider: "minimax",
920
+ authenticated: false,
921
+ checked_at: new Date().toISOString(),
922
+ page_url: "",
923
+ detected_text_sample: null,
924
+ profile_path: liveSession.profile_path,
925
+ error_message:
926
+ "Live attach failed: " +
927
+ (result.error_message ?? "unknown error") +
928
+ ". Is the Chrome window still open?",
929
+ };
930
+ }
931
+
932
+ // 2. Fall back to headless Playwright with existing profile
933
+ const profileExists = hasProfileData(profileDir);
268
934
  if (!profileExists) {
269
935
  console.log("⚠️ No profile found. Run auth first:");
270
936
  console.log(" bun packages/auth/src/run-minimax-auth.ts auth");
@@ -279,90 +945,74 @@ export async function scrapeWithExistingProfile(
279
945
  };
280
946
  }
281
947
 
282
- let context: BrowserContext | null = null;
948
+ console.log("Launching headless browser with saved profile...");
283
949
 
950
+ let context: BrowserContext | null = null;
284
951
  try {
285
- // Launch with existing profile (headless for scraping)
286
- console.log("🚀 Launching browser with saved profile...");
287
- console.log(" (Silent/headless mode for scraping)");
288
-
289
952
  context = await chromium.launchPersistentContext(profileDir, {
290
- headless: true, // Silent mode for scraping
953
+ executablePath: getChromeExecutablePath(config),
954
+ headless: true,
291
955
  chromiumSandbox: false,
292
- args: ["--no-first-run", "--no-default-browser-check"],
956
+ args: getChromeArgs(),
293
957
  viewport: { width: 1280, height: 800 },
294
958
  });
295
959
 
296
960
  const page = context.pages()[0] ?? (await context.newPage());
297
-
298
- // Navigate to usage
299
- console.log("🖥️ Navigating to usage page...");
300
- await page.goto(targetUrl, { waitUntil: "networkidle", timeout: 30000 });
301
- await page.waitForTimeout(2000);
961
+ console.log("Navigating to " + targetUrl + "...");
962
+ await page.goto(targetUrl, {
963
+ waitUntil: "domcontentloaded",
964
+ timeout: 30000,
965
+ });
966
+ await page.waitForTimeout(3000);
302
967
 
303
968
  const url = page.url();
304
969
  const bodyText = (await page.textContent("body")) ?? "";
305
970
  const { detected, sample } = detectUsagePage(bodyText);
971
+ const usage_lines = extractUsageLines(bodyText);
306
972
 
307
- console.log("");
308
- console.log(` URL: ${url}`);
309
- console.log(` Detected usage page: ${detected}`);
310
-
311
- // Extract all numbers/percentages
312
- const usageData: string[] = [];
313
- const elements = await page.$$("body *");
314
- for (const el of elements.slice(0, 300)) {
315
- const text = await el.textContent();
316
- if (
317
- text &&
318
- (text.includes("%") ||
319
- text.match(/\d+\s*[/:]\s*\d+/) ||
320
- (/^\d+$/.test(text.trim()) && text.length < 10))
321
- ) {
322
- const clean = text.trim().substring(0, 50);
323
- if (!usageData.includes(clean)) {
324
- usageData.push(clean);
325
- }
326
- }
327
- }
973
+ // Check if redirected to login
974
+ const redirectedToLogin =
975
+ url.includes("unified-login") || url.includes("login");
976
+ const savedStatus = loadSavedAuthStatus(config);
977
+ const wasPreviouslyAuthenticated = savedStatus?.authenticated ?? false;
328
978
 
329
- if (usageData.length > 0) {
330
- console.log("");
331
- console.log("📊 Usage Data Found:");
332
- usageData.slice(0, 15).forEach((item) => {
333
- console.log(` - ${item}`);
334
- });
979
+ let authenticated = detected;
980
+ let error_message: string | undefined;
981
+
982
+ if (redirectedToLogin && wasPreviouslyAuthenticated) {
983
+ console.log(
984
+ "⚠️ Redirected to login but previously authenticated. Profile may have expired.",
985
+ );
986
+ authenticated = false;
987
+ error_message = "Session expired - redirected to login";
335
988
  }
336
989
 
337
- console.log("");
990
+ console.log("URL: " + url);
991
+ console.log("Detected usage page: " + detected);
992
+ if (usage_lines.length > 0) {
993
+ console.log("Usage lines (" + usage_lines.length + "):");
994
+ usage_lines.slice(0, 20).forEach((l) => console.log(" " + l));
995
+ }
338
996
 
339
997
  const status: MinimaxAuthStatus = {
340
998
  provider: "minimax",
341
- authenticated: detected,
999
+ authenticated,
342
1000
  checked_at: new Date().toISOString(),
343
1001
  page_url: url,
344
1002
  detected_text_sample: sample,
345
1003
  profile_path: profileDir,
1004
+ usage_lines,
1005
+ error_message,
346
1006
  };
347
-
348
1007
  saveAuthStatus(status, config);
349
1008
 
350
- if (detected) {
351
- console.log("✅ Scraped successfully!");
352
- }
353
-
354
1009
  await context.close();
355
1010
  context = null;
356
-
357
1011
  return status;
358
1012
  } catch (error) {
359
1013
  const errorMessage = error instanceof Error ? error.message : String(error);
360
- console.error(`❌ Error: ${errorMessage}`);
361
-
362
- if (context) {
363
- await context.close().catch(() => {});
364
- }
365
-
1014
+ console.error("❌ Error: " + errorMessage);
1015
+ if (context) await context.close().catch(() => {});
366
1016
  const status: MinimaxAuthStatus = {
367
1017
  provider: "minimax",
368
1018
  authenticated: false,
@@ -372,23 +1022,20 @@ export async function scrapeWithExistingProfile(
372
1022
  profile_path: profileDir,
373
1023
  error_message: errorMessage,
374
1024
  };
375
-
376
1025
  saveAuthStatus(status, config);
377
1026
  return status;
378
1027
  }
379
1028
  }
380
1029
 
381
1030
  /**
382
- * Check auth status
1031
+ * Check auth status.
383
1032
  */
384
1033
  export async function checkAuthStatus(
385
1034
  config: MinimaxBrowserAuthConfig = {},
386
1035
  ): Promise<MinimaxAuthStatus> {
387
1036
  const statusPath = config.statusPath ?? getStatusPath();
388
1037
  const profileDir = config.profilePath ?? getProfileDir();
389
-
390
1038
  console.log("Checking MiniMax authentication status...");
391
-
392
1039
  if (fs.existsSync(statusPath)) {
393
1040
  const content = fs.readFileSync(statusPath, "utf-8");
394
1041
  try {
@@ -399,29 +1046,36 @@ export async function checkAuthStatus(
399
1046
  ? "✅ User is logged in"
400
1047
  : "⚠️ User is NOT logged in",
401
1048
  );
402
- console.log(` Last checked: ${status.checked_at}`);
403
- console.log(` Profile: ${status.profile_path}`);
1049
+ if (status.usage_lines && status.usage_lines.length > 0) {
1050
+ console.log("Usage lines available: " + status.usage_lines.length);
1051
+ }
1052
+ console.log("Checked at: " + status.checked_at);
1053
+ console.log("Profile: " + status.profile_path);
404
1054
  return status;
405
1055
  } catch {
406
- console.log("Invalid status file. Run auth first.");
1056
+ console.log("⚠️ Status file is corrupted");
407
1057
  }
1058
+ } else {
1059
+ console.log("⚠️ No status file found. Run auth first.");
408
1060
  }
409
1061
 
410
- const profileExists = fs.existsSync(path.join(profileDir, "SingletonLock"));
411
- if (profileExists) {
412
- console.log("");
413
- console.log(" Profile exists - run 'scrape' to check usage");
414
- return {
415
- provider: "minimax",
416
- authenticated: true,
417
- checked_at: new Date().toISOString(),
418
- page_url: "",
419
- detected_text_sample: null,
420
- profile_path: profileDir,
421
- };
1062
+ const liveSession = loadLiveBrowserSession();
1063
+ if (liveSession) {
1064
+ console.log(
1065
+ "Live browser session active (PID " +
1066
+ liveSession.pid +
1067
+ ", port " +
1068
+ liveSession.debugging_port +
1069
+ ")",
1070
+ );
1071
+ }
1072
+
1073
+ if (!hasProfileData(profileDir)) {
1074
+ console.log("⚠️ No Chrome profile found at " + profileDir);
1075
+ } else {
1076
+ console.log("✅ Chrome profile exists at " + profileDir);
422
1077
  }
423
1078
 
424
- console.log("No profile found. Run auth first.");
425
1079
  return {
426
1080
  provider: "minimax",
427
1081
  authenticated: false,
@@ -429,9 +1083,5 @@ export async function checkAuthStatus(
429
1083
  page_url: "",
430
1084
  detected_text_sample: null,
431
1085
  profile_path: profileDir,
432
- error_message: "No profile found - run auth first",
433
1086
  };
434
1087
  }
435
-
436
- // Alias for backward compatibility
437
- export const authenticateWithCurator = authenticateWithPersistentBrowser;