@elvatis_com/openclaw-cli-bridge-elvatis 0.2.26 → 0.2.28

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > OpenClaw plugin that bridges locally installed AI CLIs (Codex, Gemini, Claude Code) as model providers — with slash commands for instant model switching, restore, health testing, and model listing.
4
4
 
5
- **Current version:** `0.2.26`
5
+ **Current version:** `0.2.28`
6
6
 
7
7
  ---
8
8
 
@@ -287,6 +287,19 @@ npm test # vitest run (45 tests)
287
287
 
288
288
  ## Changelog
289
289
 
290
+ ### v0.2.28
291
+ - **feat:** `/grok-login` scans auth cookie expiry (sso cookie) and saves to `~/.openclaw/grok-cookie-expiry.json`
292
+ - **feat:** `/grok-status` shows cookie expiry with color-coded warnings (🚨 <7d, ⚠️ <14d, ✅ otherwise)
293
+ - **feat:** Startup log shows cookie expiry and refreshes the expiry file on session restore
294
+ - **fix:** Flaky cli-runner test improved (was pre-existing)
295
+
296
+ ### v0.2.27
297
+ - **feat:** Grok persistent Chromium profile (`~/.openclaw/grok-profile/`) — cookies survive gateway restarts
298
+ - **feat:** `/grok-login` imports cookies from OpenClaw browser into persistent profile automatically
299
+ - **fix:** `verifySession` reuses existing grok.com page instead of opening a new one (avoids Cloudflare 403)
300
+ - **fix:** DOM-polling strategy instead of direct fetch API — bypasses `x-statsig-id` anti-bot check completely
301
+ - **fix:** Lazy-connect: `connectGrokContext` callback auto-reconnects on first request after restart
302
+
290
303
  ### v0.2.26
291
304
  - **feat:** Grok web-session bridge integrated into cli-bridge proxy — routes `web-grok/*` models through grok.com browser session (SuperGrok subscription, no API credits needed)
292
305
  - **feat:** `/grok-login` — opens Chromium for X.com OAuth login, saves session to `~/.openclaw/grok-session.json`
package/SKILL.md CHANGED
@@ -53,4 +53,4 @@ Each command runs `openclaw models set <model>` atomically and replies with a co
53
53
 
54
54
  See `README.md` for full configuration reference and architecture diagram.
55
55
 
56
- **Version:** 0.2.26
56
+ **Version:** 0.2.28
package/index.ts CHANGED
@@ -86,31 +86,135 @@ interface CliPluginConfig {
86
86
  let grokBrowser: Browser | null = null;
87
87
  let grokContext: BrowserContext | null = null;
88
88
 
89
- async function launchGrokBrowser(): Promise<Browser> {
89
+ // Persistent profile dir — survives gateway restarts, keeps cookies intact
90
+ const GROK_PROFILE_DIR = join(homedir(), ".openclaw", "grok-profile");
91
+
92
+ // Cookie expiry tracking file — written on /grok-login, read on startup
93
+ const GROK_EXPIRY_FILE = join(homedir(), ".openclaw", "grok-cookie-expiry.json");
94
+
95
+ interface GrokExpiryInfo {
96
+ expiresAt: number; // epoch ms — earliest auth cookie expiry
97
+ loginAt: number; // epoch ms — when /grok-login was last run
98
+ cookieName: string; // which cookie determines the expiry
99
+ }
100
+
101
+ function saveGrokExpiry(info: GrokExpiryInfo): void {
102
+ try {
103
+ writeFileSync(GROK_EXPIRY_FILE, JSON.stringify(info, null, 2));
104
+ } catch { /* ignore */ }
105
+ }
106
+
107
+ function loadGrokExpiry(): GrokExpiryInfo | null {
108
+ try {
109
+ const raw = readFileSync(GROK_EXPIRY_FILE, "utf-8");
110
+ return JSON.parse(raw) as GrokExpiryInfo;
111
+ } catch { return null; }
112
+ }
113
+
114
+ /** Returns human-readable expiry summary e.g. "179 days (2026-09-07)" */
115
+ function formatExpiryInfo(info: GrokExpiryInfo): string {
116
+ const daysLeft = Math.ceil((info.expiresAt - Date.now()) / 86_400_000);
117
+ const dateStr = new Date(info.expiresAt).toISOString().split("T")[0];
118
+ if (daysLeft <= 0) return `⚠️ EXPIRED (was ${dateStr})`;
119
+ if (daysLeft <= 7) return `🚨 expires in ${daysLeft}d (${dateStr}) — run /grok-login NOW`;
120
+ if (daysLeft <= 14) return `⚠️ expires in ${daysLeft}d (${dateStr}) — run /grok-login soon`;
121
+ return `✅ valid for ${daysLeft} more days (expires ${dateStr})`;
122
+ }
123
+
124
+ /** Scan context cookies and return earliest auth cookie expiry */
125
+ async function scanCookieExpiry(ctx: import("playwright").BrowserContext): Promise<GrokExpiryInfo | null> {
126
+ try {
127
+ const cookies = await ctx.cookies(["https://grok.com", "https://x.ai"]);
128
+ const authCookies = cookies.filter((c) => ["sso", "sso-rw"].includes(c.name) && c.expires > 0);
129
+ if (authCookies.length === 0) return null;
130
+ const earliest = authCookies.reduce((min, c) => (c.expires < min.expires ? c : min));
131
+ return {
132
+ expiresAt: earliest.expires * 1000,
133
+ loginAt: Date.now(),
134
+ cookieName: earliest.name,
135
+ };
136
+ } catch { return null; }
137
+ }
138
+
139
+ /**
140
+ * Launch (or reuse) a persistent headless Chromium context for grok.com.
141
+ * Uses launchPersistentContext so cookies survive gateway restarts.
142
+ * The profile lives at ~/.openclaw/grok-profile/
143
+ */
144
+ async function getOrLaunchGrokContext(
145
+ log: (msg: string) => void
146
+ ): Promise<BrowserContext | null> {
147
+ // Already have a live context?
148
+ if (grokContext) {
149
+ try {
150
+ // Quick check: can we still enumerate pages?
151
+ grokContext.pages();
152
+ return grokContext;
153
+ } catch {
154
+ grokContext = null;
155
+ }
156
+ }
157
+
90
158
  const { chromium } = await import("playwright");
91
- return chromium.launch({ headless: false });
159
+
160
+ // 1. Try connecting to the OpenClaw managed browser first (user may have grok.com open)
161
+ try {
162
+ const browser = await chromium.connectOverCDP("http://127.0.0.1:18800", { timeout: 2000 });
163
+ grokBrowser = browser;
164
+ const ctx = browser.contexts()[0];
165
+ if (ctx) {
166
+ log("[cli-bridge:grok] connected to OpenClaw browser");
167
+ return ctx;
168
+ }
169
+ } catch {
170
+ // OpenClaw browser not available — fall through to persistent context
171
+ }
172
+
173
+ // 2. Launch our own persistent headless Chromium with saved profile
174
+ log("[cli-bridge:grok] launching persistent Chromium…");
175
+ try {
176
+ const ctx = await chromium.launchPersistentContext(GROK_PROFILE_DIR, {
177
+ headless: true,
178
+ args: ["--no-sandbox", "--disable-setuid-sandbox"],
179
+ });
180
+ grokContext = ctx;
181
+ log("[cli-bridge:grok] persistent context ready");
182
+ return ctx;
183
+ } catch (err) {
184
+ log(`[cli-bridge:grok] failed to launch browser: ${(err as Error).message}`);
185
+ return null;
186
+ }
187
+ }
188
+
189
+ async function connectToOpenClawBrowser(
190
+ log: (msg: string) => void
191
+ ): Promise<BrowserContext | null> {
192
+ return getOrLaunchGrokContext(log);
92
193
  }
93
194
 
94
195
  async function tryRestoreGrokSession(
95
- sessionPath: string,
196
+ _sessionPath: string,
96
197
  log: (msg: string) => void
97
198
  ): Promise<boolean> {
98
- const saved = loadSession(sessionPath);
99
- if (!saved || isSessionExpiredByAge(saved)) {
100
- log("[cli-bridge:grok] no valid saved session");
101
- return false;
102
- }
103
199
  try {
104
- if (!grokBrowser) grokBrowser = await launchGrokBrowser();
105
- const ctx = await createContextFromSession(grokBrowser, saved);
200
+ const ctx = await getOrLaunchGrokContext(log);
201
+ if (!ctx) return false;
202
+
106
203
  const check = await verifySession(ctx, log);
107
204
  if (!check.valid) {
108
- log(`[cli-bridge:grok] saved session invalid: ${check.reason}`);
109
- await ctx.close().catch(() => {});
205
+ log(`[cli-bridge:grok] session invalid: ${check.reason}`);
110
206
  return false;
111
207
  }
112
208
  grokContext = ctx;
113
209
  log("[cli-bridge:grok] session restored ✅");
210
+ // Log cookie expiry status on startup
211
+ const expiry = loadGrokExpiry();
212
+ if (expiry) {
213
+ log(`[cli-bridge:grok] cookie expiry: ${formatExpiryInfo(expiry)}`);
214
+ // Re-scan to keep expiry file fresh (cookies may have been renewed)
215
+ const freshExpiry = await scanCookieExpiry(ctx);
216
+ if (freshExpiry) saveGrokExpiry(freshExpiry);
217
+ }
114
218
  return true;
115
219
  } catch (err) {
116
220
  log(`[cli-bridge:grok] session restore error: ${(err as Error).message}`);
@@ -443,7 +547,7 @@ function proxyTestRequest(
443
547
  const plugin = {
444
548
  id: "openclaw-cli-bridge-elvatis",
445
549
  name: "OpenClaw CLI Bridge",
446
- version: "0.2.26",
550
+ version: "0.2.28",
447
551
  description:
448
552
  "Phase 1: openai-codex auth bridge. " +
449
553
  "Phase 2: HTTP proxy for gemini/claude CLIs. " +
@@ -558,6 +662,14 @@ const plugin = {
558
662
  log: (msg) => api.logger.info(msg),
559
663
  warn: (msg) => api.logger.warn(msg),
560
664
  getGrokContext: () => grokContext,
665
+ connectGrokContext: async () => {
666
+ const ctx = await connectToOpenClawBrowser((msg) => api.logger.info(msg));
667
+ if (ctx) {
668
+ const check = await verifySession(ctx, (msg) => api.logger.info(msg));
669
+ if (check.valid) { grokContext = ctx; return ctx; }
670
+ }
671
+ return null;
672
+ },
561
673
  });
562
674
  proxyServer = server;
563
675
  api.logger.info(
@@ -582,6 +694,14 @@ const plugin = {
582
694
  log: (msg) => api.logger.info(msg),
583
695
  warn: (msg) => api.logger.warn(msg),
584
696
  getGrokContext: () => grokContext,
697
+ connectGrokContext: async () => {
698
+ const ctx = await connectToOpenClawBrowser((msg) => api.logger.info(msg));
699
+ if (ctx) {
700
+ const check = await verifySession(ctx, (msg) => api.logger.info(msg));
701
+ if (check.valid) { grokContext = ctx; return ctx; }
702
+ }
703
+ return null;
704
+ },
585
705
  });
586
706
  proxyServer = server;
587
707
  api.logger.info(`[cli-bridge] proxy ready on :${port} (retry)`);
@@ -831,20 +951,63 @@ const plugin = {
831
951
 
832
952
  api.registerCommand({
833
953
  name: "grok-login",
834
- description: "Open browser to log in to grok.com (X/Twitter account)",
954
+ description: "Authenticate grok.com: imports cookies from OpenClaw browser into persistent profile",
835
955
  handler: async (): Promise<PluginCommandResult> => {
836
956
  if (grokContext) {
837
- return { text: "✅ Already logged in to grok.com. Use /grok-logout first to re-authenticate." };
957
+ const check = await verifySession(grokContext, (msg) => api.logger.info(msg));
958
+ if (check.valid) {
959
+ return { text: "✅ Already connected to grok.com. Use `/grok-logout` first to reset." };
960
+ }
961
+ grokContext = null;
838
962
  }
839
- api.logger.info("[cli-bridge:grok] starting interactive login...");
963
+ api.logger.info("[cli-bridge:grok] /grok-login: importing session from OpenClaw browser…");
964
+ const { chromium } = await import("playwright");
965
+
966
+ // Step 1: try to grab cookies from the OpenClaw browser (user must have grok.com open)
967
+ let importedCookies: unknown[] = [];
840
968
  try {
841
- if (!grokBrowser) grokBrowser = await launchGrokBrowser();
842
- const session = await runInteractiveLogin(grokBrowser, grokSessionPath, (msg) => api.logger.info(msg));
843
- grokContext = await createContextFromSession(grokBrowser, session);
844
- return { text: "✅ Logged in to grok.com!\n\nGrok models available:\n• `vllm/web-grok/grok-3`\n• `vllm/web-grok/grok-3-fast`\n• `vllm/web-grok/grok-3-mini`\n\nUse `/cli-grok` to switch." };
845
- } catch (err) {
846
- return { text: `❌ Login failed: ${(err as Error).message}` };
969
+ const ocBrowser = await chromium.connectOverCDP("http://127.0.0.1:18800", { timeout: 3000 });
970
+ const ocCtx = ocBrowser.contexts()[0];
971
+ if (ocCtx) {
972
+ importedCookies = await ocCtx.cookies(["https://grok.com", "https://x.ai", "https://accounts.x.ai"]);
973
+ api.logger.info(`[cli-bridge:grok] imported ${importedCookies.length} cookies from OpenClaw browser`);
974
+ }
975
+ await ocBrowser.close().catch(() => {});
976
+ } catch {
977
+ api.logger.info("[cli-bridge:grok] OpenClaw browser not available — using saved profile");
978
+ }
979
+
980
+ // Step 2: launch/connect persistent context and inject cookies
981
+ const ctx = await getOrLaunchGrokContext((msg) => api.logger.info(msg));
982
+ if (!ctx) return { text: "❌ Could not launch browser. Check server logs." };
983
+
984
+ if (importedCookies.length > 0) {
985
+ await ctx.addCookies(importedCookies as Parameters<typeof ctx.addCookies>[0]);
986
+ api.logger.info(`[cli-bridge:grok] cookies injected into persistent profile`);
987
+ }
988
+
989
+ // Step 3: navigate to grok.com and verify
990
+ const pages = ctx.pages();
991
+ const page = pages.find(p => p.url().includes("grok.com")) ?? await ctx.newPage();
992
+ if (!page.url().includes("grok.com")) {
993
+ await page.goto("https://grok.com", { waitUntil: "domcontentloaded", timeout: 15_000 });
994
+ }
995
+
996
+ const check = await verifySession(ctx, (msg) => api.logger.info(msg));
997
+ if (!check.valid) {
998
+ return { text: `❌ Session not valid: ${check.reason}\n\nMake sure grok.com is open in your browser and you're logged in, then run /grok-login again.` };
847
999
  }
1000
+ grokContext = ctx;
1001
+
1002
+ // Scan cookie expiry and persist it
1003
+ const expiry = await scanCookieExpiry(ctx);
1004
+ if (expiry) {
1005
+ saveGrokExpiry(expiry);
1006
+ api.logger.info(`[cli-bridge:grok] cookie expiry: ${new Date(expiry.expiresAt).toISOString()}`);
1007
+ }
1008
+ const expiryLine = expiry ? `\n\n🕐 Cookie expiry: ${formatExpiryInfo(expiry)}` : "";
1009
+
1010
+ return { text: `✅ Grok session ready!\n\nModels available:\n• \`vllm/web-grok/grok-3\`\n• \`vllm/web-grok/grok-3-fast\`\n• \`vllm/web-grok/grok-3-mini\`\n• \`vllm/web-grok/grok-3-mini-fast\`${expiryLine}` };
848
1011
  },
849
1012
  } satisfies OpenClawPluginCommandDefinition);
850
1013
 
@@ -857,7 +1020,9 @@ const plugin = {
857
1020
  }
858
1021
  const check = await verifySession(grokContext, (msg) => api.logger.info(msg));
859
1022
  if (check.valid) {
860
- return { text: `✅ grok.com session active\nProxy: \`127.0.0.1:${port}\`\nModels: web-grok/grok-3, web-grok/grok-3-fast, web-grok/grok-3-mini, web-grok/grok-3-mini-fast` };
1023
+ const expiry = loadGrokExpiry();
1024
+ const expiryLine = expiry ? `\n🕐 ${formatExpiryInfo(expiry)}` : "";
1025
+ return { text: `✅ grok.com session active\nProxy: \`127.0.0.1:${port}\`\nModels: web-grok/grok-3, web-grok/grok-3-fast, web-grok/grok-3-mini, web-grok/grok-3-mini-fast${expiryLine}` };
861
1026
  }
862
1027
  grokContext = null;
863
1028
  return { text: `❌ Session expired: ${check.reason}\nRun \`/grok-login\` to re-authenticate.` };
@@ -866,14 +1031,12 @@ const plugin = {
866
1031
 
867
1032
  api.registerCommand({
868
1033
  name: "grok-logout",
869
- description: "Clear saved grok.com session",
1034
+ description: "Disconnect from grok.com session (does not close the browser)",
870
1035
  handler: async (): Promise<PluginCommandResult> => {
871
- if (grokContext) {
872
- await grokContext.close().catch(() => {});
873
- grokContext = null;
874
- }
1036
+ // Don't close the context — it belongs to the OpenClaw browser, not us
1037
+ grokContext = null;
875
1038
  deleteSession(grokSessionPath);
876
- return { text: "✅ Logged out from grok.com. Session file deleted." };
1039
+ return { text: "✅ Disconnected from grok.com. Run `/grok-login` to reconnect." };
877
1040
  },
878
1041
  } satisfies OpenClawPluginCommandDefinition);
879
1042
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-cli-bridge-elvatis",
3
3
  "name": "OpenClaw CLI Bridge",
4
- "version": "0.2.26",
4
+ "version": "0.2.28",
5
5
  "description": "Phase 1: openai-codex auth bridge. Phase 2: local HTTP proxy routing model calls through gemini/claude CLIs (vllm provider).",
6
6
  "providers": [
7
7
  "openai-codex"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elvatis_com/openclaw-cli-bridge-elvatis",
3
- "version": "0.2.26",
3
+ "version": "0.2.28",
4
4
  "description": "Bridges gemini, claude, and codex CLI tools as OpenClaw model providers. Reads existing CLI auth without re-login.",
5
5
  "type": "module",
6
6
  "openclaw": {
@@ -1,16 +1,18 @@
1
1
  /**
2
2
  * grok-client.ts
3
3
  *
4
- * HTTP client that sends chat completion requests to grok.com's internal REST API
5
- * using an authenticated browser session (cookies).
4
+ * Grok.com integration via Playwright DOM automation.
6
5
  *
7
- * Endpoint: POST https://grok.com/rest/app-chat/conversations/new
8
- * Response: Server-Sent Events (SSE) stream
6
+ * Strategy: inject messages via the ProseMirror editor, poll `.message-bubble`
7
+ * DOM elements for the response. This bypasses Cloudflare anti-bot checks
8
+ * on direct API calls (which require signed x-statsig-id headers generated
9
+ * inside the page's own bundle — not accessible externally).
9
10
  *
10
- * This mimics what the grok.com web UI does internally.
11
+ * Works by connecting to the running OpenClaw browser (CDP port 18800) which
12
+ * already has an authenticated grok.com session open.
11
13
  */
12
14
 
13
- import type { BrowserContext } from "playwright";
15
+ import type { BrowserContext, Page } from "playwright";
14
16
 
15
17
  // ──────────────────────────────────────────────────────────────────────────────
16
18
  // Types
@@ -23,10 +25,7 @@ export interface ChatMessage {
23
25
 
24
26
  export interface GrokCompleteOptions {
25
27
  messages: ChatMessage[];
26
- model?: string; // "grok-3" | "grok-3-fast" | "grok-3-mini" | "grok-3-mini-fast"
27
- stream?: boolean;
28
- maxTokens?: number;
29
- temperature?: number;
28
+ model?: string;
30
29
  timeoutMs?: number;
31
30
  }
32
31
 
@@ -34,395 +33,217 @@ export interface GrokCompleteResult {
34
33
  content: string;
35
34
  model: string;
36
35
  finishReason: string;
37
- /** estimated — grok.com doesn't expose exact token counts */
38
36
  promptTokens?: number;
39
37
  completionTokens?: number;
40
38
  }
41
39
 
42
- /** SSE token event from grok.com */
43
- interface GrokTokenEvent {
44
- result?: {
45
- response?: {
46
- token?: string;
47
- finalMetadata?: {
48
- inputTokenCount?: number;
49
- outputTokenCount?: number;
50
- };
51
- modelResponse?: {
52
- responseId?: string;
53
- message?: string;
54
- };
55
- };
56
- isSoftStop?: boolean;
57
- };
58
- error?: string;
59
- }
60
-
61
40
  // ──────────────────────────────────────────────────────────────────────────────
62
- // Model ID mapping: OpenAI-style → grok.com internal IDs
41
+ // Constants
63
42
  // ──────────────────────────────────────────────────────────────────────────────
64
43
 
65
- const MODEL_MAP: Record<string, string> = {
66
- "grok-3": "grok-3",
67
- "grok-3-fast": "grok-3-fast",
68
- "grok-3-mini": "grok-3-mini",
69
- "grok-3-mini-fast": "grok-3-mini-fast",
70
- // aliases
71
- "grok": "grok-3",
72
- "grok-fast": "grok-3-fast",
73
- "grok-mini": "grok-3-mini",
74
- };
75
-
76
- function resolveModel(model?: string): string {
77
- if (!model) return "grok-3";
78
- return MODEL_MAP[model] ?? model;
79
- }
44
+ const DEFAULT_TIMEOUT_MS = 120_000;
45
+ const STABLE_CHECKS = 3; // consecutive identical reads to consider "done"
46
+ const STABLE_INTERVAL_MS = 500; // ms between stability checks
80
47
 
81
48
  // ──────────────────────────────────────────────────────────────────────────────
82
- // Request builder
49
+ // Helpers
83
50
  // ──────────────────────────────────────────────────────────────────────────────
84
51
 
85
- /** Build the request body for grok.com's internal API */
86
- function buildRequestBody(opts: GrokCompleteOptions): Record<string, unknown> {
87
- const model = resolveModel(opts.model);
88
-
89
- // Combine messages into a single user prompt (grok.com web doesn't expose multi-turn directly)
90
- // System prompt → prepended to first user message
91
- const systemMsgs = opts.messages.filter((m) => m.role === "system");
92
- const convMsgs = opts.messages.filter((m) => m.role !== "system");
52
+ function resolveModel(m?: string): string {
53
+ const clean = (m ?? "grok-3").replace("web-grok/", "");
54
+ const allowed = ["grok-3", "grok-3-fast", "grok-3-mini", "grok-3-mini-fast"];
55
+ return allowed.includes(clean) ? clean : "grok-3";
56
+ }
93
57
 
94
- let userPrompt = "";
95
- if (systemMsgs.length > 0) {
96
- userPrompt = systemMsgs.map((m) => m.content).join("\n") + "\n\n";
97
- }
58
+ /**
59
+ * Flatten a multi-turn message array into a single string for the Grok UI.
60
+ */
61
+ function flattenMessages(messages: ChatMessage[]): string {
62
+ if (messages.length === 1) return messages[0].content;
63
+ return messages
64
+ .map((m) => {
65
+ if (m.role === "system") return `[System]: ${m.content}`;
66
+ if (m.role === "assistant") return `[Assistant]: ${m.content}`;
67
+ return m.content;
68
+ })
69
+ .join("\n\n");
70
+ }
98
71
 
99
- // Build conversation history for multi-turn
100
- const history: Array<{ role: string; content: string }> = [];
101
- for (let i = 0; i < convMsgs.length - 1; i++) {
102
- history.push({ role: convMsgs[i].role, content: convMsgs[i].content });
103
- }
104
- const lastMsg = convMsgs[convMsgs.length - 1];
105
- userPrompt += lastMsg?.content ?? "";
106
-
107
- return {
108
- temporary: false,
109
- modelName: model,
110
- message: userPrompt,
111
- fileAttachments: [],
112
- imageAttachments: [],
113
- disableSearch: false,
114
- enableImageGeneration: false,
115
- returnImageBytes: false,
116
- returnRawGrokInXaiRequest: false,
117
- enableSideBySide: false,
118
- isReasoning: model.includes("mini"), // mini models support reasoning
119
- conversationHistory: history,
120
- toolOverrides: {},
121
- enableCustomization: false,
122
- deepsearchPreset: "",
123
- isPreset: false,
124
- };
72
+ /**
73
+ * Get an existing grok.com page from the context, or navigate to grok.com.
74
+ */
75
+ export async function getOrCreateGrokPage(
76
+ context: BrowserContext
77
+ ): Promise<{ page: Page; owned: boolean }> {
78
+ const existing = context.pages().filter((p) => p.url().startsWith("https://grok.com"));
79
+ if (existing.length > 0) return { page: existing[0], owned: false };
80
+ const page = await context.newPage();
81
+ await page.goto("https://grok.com", { waitUntil: "domcontentloaded", timeout: 15_000 });
82
+ return { page, owned: true };
125
83
  }
126
84
 
127
85
  // ──────────────────────────────────────────────────────────────────────────────
128
- // SSE parser
86
+ // Core DOM automation
129
87
  // ──────────────────────────────────────────────────────────────────────────────
130
88
 
131
- function parseSSELine(line: string): GrokTokenEvent | null {
132
- if (!line.startsWith("data: ")) return null;
133
- const data = line.slice(6).trim();
134
- if (data === "[DONE]") return null;
135
- try {
136
- return JSON.parse(data) as GrokTokenEvent;
137
- } catch {
138
- return null;
89
+ /**
90
+ * Send a message via the grok.com UI and wait for a stable response.
91
+ * Returns the final text content of the last `.message-bubble` element.
92
+ */
93
+ async function sendAndWait(
94
+ page: Page,
95
+ message: string,
96
+ timeoutMs: number,
97
+ log: (msg: string) => void
98
+ ): Promise<string> {
99
+ // Count current message bubbles
100
+ const countBefore = await page.evaluate(
101
+ () => document.querySelectorAll(".message-bubble").length
102
+ );
103
+
104
+ // Type the message into the ProseMirror editor
105
+ await page.evaluate((msg: string) => {
106
+ const ed =
107
+ document.querySelector(".ProseMirror") ||
108
+ document.querySelector('[contenteditable="true"]');
109
+ if (!ed) throw new Error("Grok editor not found");
110
+ (ed as HTMLElement).focus();
111
+ document.execCommand("insertText", false, msg);
112
+ }, message);
113
+
114
+ await new Promise((r) => setTimeout(r, 300));
115
+ await page.keyboard.press("Enter");
116
+
117
+ log(`grok-client: message sent (${message.length} chars), waiting for response…`);
118
+
119
+ // Poll for a stable response
120
+ const deadline = Date.now() + timeoutMs;
121
+ let lastText = "";
122
+ let stableCount = 0;
123
+
124
+ while (Date.now() < deadline) {
125
+ await new Promise((r) => setTimeout(r, STABLE_INTERVAL_MS));
126
+
127
+ const text = await page.evaluate(
128
+ (before: number) => {
129
+ const bubbles = [...document.querySelectorAll(".message-bubble")];
130
+ if (bubbles.length <= before) return "";
131
+ return bubbles[bubbles.length - 1].textContent?.trim() ?? "";
132
+ },
133
+ countBefore
134
+ );
135
+
136
+ if (text && text === lastText) {
137
+ stableCount++;
138
+ if (stableCount >= STABLE_CHECKS) {
139
+ log(`grok-client: response stable (${text.length} chars)`);
140
+ return text;
141
+ }
142
+ } else {
143
+ stableCount = 0;
144
+ lastText = text;
145
+ }
139
146
  }
147
+
148
+ throw new Error(`grok.com response timeout after ${timeoutMs}ms`);
140
149
  }
141
150
 
142
151
  // ──────────────────────────────────────────────────────────────────────────────
143
- // Main client function
152
+ // Public API
144
153
  // ──────────────────────────────────────────────────────────────────────────────
145
154
 
146
- const GROK_API_URL = "https://grok.com/rest/app-chat/conversations/new";
147
- const DEFAULT_TIMEOUT_MS = 120_000;
148
-
149
155
  /**
150
- * Complete a chat via grok.com's internal API using a browser session context.
151
- * Uses page.evaluate to make the fetch from inside the authenticated browser context.
156
+ * Non-streaming completion.
152
157
  */
153
158
  export async function grokComplete(
154
159
  context: BrowserContext,
155
160
  opts: GrokCompleteOptions,
156
161
  log: (msg: string) => void
157
162
  ): Promise<GrokCompleteResult> {
158
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
163
+ const { page, owned } = await getOrCreateGrokPage(context);
159
164
  const model = resolveModel(opts.model);
160
- const body = buildRequestBody(opts);
161
-
162
- log(`grok-client: POST ${GROK_API_URL} model=${model}`);
165
+ const prompt = flattenMessages(opts.messages);
166
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
163
167
 
164
- // Open a background page in the authenticated context
165
- const page = await context.newPage();
168
+ log(`grok-client: complete model=${model}`);
166
169
 
167
170
  try {
168
- // Navigate to grok.com first to ensure cookies are sent correctly
169
- await page.goto("https://grok.com", {
170
- waitUntil: "domcontentloaded",
171
- timeout: 15_000,
172
- });
173
-
174
- // Make the API call from within the page (inherits cookies automatically)
175
- const result = await page.evaluate(
176
- async ({ url, requestBody, timeout }: { url: string; requestBody: unknown; timeout: number }) => {
177
- const controller = new AbortController();
178
- const timer = setTimeout(() => controller.abort(), timeout);
179
-
180
- try {
181
- const resp = await fetch(url, {
182
- method: "POST",
183
- headers: {
184
- "Content-Type": "application/json",
185
- Accept: "text/event-stream",
186
- },
187
- body: JSON.stringify(requestBody),
188
- credentials: "include",
189
- signal: controller.signal,
190
- });
191
-
192
- if (!resp.ok) {
193
- const errText = await resp.text().catch(() => "");
194
- return {
195
- error: `HTTP ${resp.status}: ${errText.substring(0, 300)}`,
196
- content: "",
197
- };
198
- }
199
-
200
- const reader = resp.body!.getReader();
201
- const decoder = new TextDecoder();
202
- let fullText = "";
203
- let buffer = "";
204
- let inputTokens = 0;
205
- let outputTokens = 0;
206
- let finishReason = "stop";
207
-
208
- while (true) {
209
- const { done, value } = await reader.read();
210
- if (done) break;
211
-
212
- buffer += decoder.decode(value, { stream: true });
213
- const lines = buffer.split("\n");
214
- buffer = lines.pop() ?? "";
215
-
216
- for (const line of lines) {
217
- if (!line.startsWith("data: ")) continue;
218
- const data = line.slice(6).trim();
219
- if (data === "[DONE]") {
220
- finishReason = "stop";
221
- continue;
222
- }
223
- try {
224
- const evt = JSON.parse(data);
225
- const response = evt?.result?.response;
226
- if (response?.token) {
227
- fullText += response.token;
228
- }
229
- if (response?.finalMetadata) {
230
- inputTokens = response.finalMetadata.inputTokenCount ?? 0;
231
- outputTokens = response.finalMetadata.outputTokenCount ?? 0;
232
- }
233
- if (evt?.result?.isSoftStop) {
234
- finishReason = "stop";
235
- }
236
- if (evt?.error) {
237
- return { error: String(evt.error), content: fullText };
238
- }
239
- } catch {
240
- // ignore parse errors on individual SSE lines
241
- }
242
- }
243
- }
244
-
245
- return {
246
- content: fullText,
247
- inputTokens,
248
- outputTokens,
249
- finishReason,
250
- };
251
- } finally {
252
- clearTimeout(timer);
253
- }
254
- },
255
- { url: GROK_API_URL, requestBody: body, timeout: timeoutMs }
256
- );
257
-
258
- if ("error" in result && result.error) {
259
- throw new Error(`grok.com API error: ${result.error}`);
260
- }
261
-
262
- log(
263
- `grok-client: done — ${result.outputTokens ?? "?"} output tokens`
264
- );
265
-
266
- return {
267
- content: result.content ?? "",
268
- model,
269
- finishReason: result.finishReason ?? "stop",
270
- promptTokens: result.inputTokens,
271
- completionTokens: result.outputTokens,
272
- };
171
+ const content = await sendAndWait(page, prompt, timeoutMs, log);
172
+ return { content, model, finishReason: "stop" };
273
173
  } finally {
274
- await page.close();
174
+ if (owned) await page.close().catch(() => {});
275
175
  }
276
176
  }
277
177
 
278
- // ──────────────────────────────────────────────────────────────────────────────
279
- // Streaming variantyields tokens via callback
280
- // ──────────────────────────────────────────────────────────────────────────────
281
-
178
+ /**
179
+ * Streaming completionpolls the DOM and calls onToken when new text arrives.
180
+ */
282
181
  export async function grokCompleteStream(
283
182
  context: BrowserContext,
284
183
  opts: GrokCompleteOptions,
285
184
  onToken: (token: string) => void,
286
185
  log: (msg: string) => void
287
186
  ): Promise<GrokCompleteResult> {
288
- // grok.com streams via SSE; we accumulate on the JS side and call onToken per chunk.
289
- // Because page.evaluate can't stream back to Node, we use a polling approach:
290
- // write tokens to window.__grokTokenBuf, poll from Node side.
291
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
187
+ const { page, owned } = await getOrCreateGrokPage(context);
292
188
  const model = resolveModel(opts.model);
293
- const body = buildRequestBody(opts);
294
-
295
- log(`grok-client: streaming POST ${GROK_API_URL} model=${model}`);
296
-
297
- const page = await context.newPage();
189
+ const prompt = flattenMessages(opts.messages);
190
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
298
191
 
299
- try {
300
- await page.goto("https://grok.com", {
301
- waitUntil: "domcontentloaded",
302
- timeout: 15_000,
303
- });
304
-
305
- // Initialize token buffer on the page
306
- await page.evaluate(() => {
307
- (window as unknown as Record<string, unknown>).__grokTokenBuf = [];
308
- (window as unknown as Record<string, unknown>).__grokDone = false;
309
- (window as unknown as Record<string, unknown>).__grokError = null;
310
- (window as unknown as Record<string, unknown>).__grokMeta = null;
311
- });
312
-
313
- // Start the fetch in the page (non-blocking — we poll from Node)
314
- await page.evaluate(
315
- async ({ url, requestBody, timeout }: { url: string; requestBody: unknown; timeout: number }) => {
316
- const w = window as unknown as Record<string, unknown>;
317
- const controller = new AbortController();
318
- setTimeout(() => controller.abort(), timeout);
319
-
320
- (async () => {
321
- try {
322
- const resp = await fetch(url, {
323
- method: "POST",
324
- headers: {
325
- "Content-Type": "application/json",
326
- Accept: "text/event-stream",
327
- },
328
- body: JSON.stringify(requestBody),
329
- credentials: "include",
330
- signal: controller.signal,
331
- });
332
-
333
- if (!resp.ok) {
334
- const errText = await resp.text().catch(() => "");
335
- w.__grokError = `HTTP ${resp.status}: ${errText.substring(0, 300)}`;
336
- w.__grokDone = true;
337
- return;
338
- }
339
-
340
- const reader = resp.body!.getReader();
341
- const decoder = new TextDecoder();
342
- let buffer = "";
343
-
344
- while (true) {
345
- const { done, value } = await reader.read();
346
- if (done) break;
347
- buffer += decoder.decode(value, { stream: true });
348
- const lines = buffer.split("\n");
349
- buffer = lines.pop() ?? "";
350
-
351
- for (const line of lines) {
352
- if (!line.startsWith("data: ")) continue;
353
- const data = line.slice(6).trim();
354
- if (data === "[DONE]") continue;
355
- try {
356
- const evt = JSON.parse(data);
357
- const response = evt?.result?.response;
358
- if (response?.token) {
359
- (w.__grokTokenBuf as string[]).push(response.token);
360
- }
361
- if (response?.finalMetadata) {
362
- w.__grokMeta = response.finalMetadata;
363
- }
364
- if (evt?.error) {
365
- w.__grokError = String(evt.error);
366
- }
367
- } catch {
368
- // ignore
369
- }
370
- }
371
- }
372
- } catch (e: unknown) {
373
- w.__grokError = String(e);
374
- } finally {
375
- w.__grokDone = true;
376
- }
377
- })();
192
+ log(`grok-client: stream model=${model}`);
193
+
194
+ const countBefore = await page.evaluate(
195
+ () => document.querySelectorAll(".message-bubble").length
196
+ );
197
+
198
+ // Send message
199
+ await page.evaluate((msg: string) => {
200
+ const ed =
201
+ document.querySelector(".ProseMirror") ||
202
+ document.querySelector('[contenteditable="true"]');
203
+ if (!ed) throw new Error("Grok editor not found");
204
+ (ed as HTMLElement).focus();
205
+ document.execCommand("insertText", false, msg);
206
+ }, prompt);
207
+ await new Promise((r) => setTimeout(r, 300));
208
+ await page.keyboard.press("Enter");
209
+
210
+ log(`grok-client: message sent, streaming…`);
211
+
212
+ // Stream: poll DOM, emit new chars as tokens
213
+ const deadline = Date.now() + timeoutMs;
214
+ let emittedLength = 0;
215
+ let lastText = "";
216
+ let stableCount = 0;
217
+
218
+ while (Date.now() < deadline) {
219
+ await new Promise((r) => setTimeout(r, STABLE_INTERVAL_MS));
220
+
221
+ const text = await page.evaluate(
222
+ (before: number) => {
223
+ const bubbles = [...document.querySelectorAll(".message-bubble")];
224
+ if (bubbles.length <= before) return "";
225
+ return bubbles[bubbles.length - 1].textContent?.trim() ?? "";
378
226
  },
379
- { url: GROK_API_URL, requestBody: body, timeout: timeoutMs }
227
+ countBefore
380
228
  );
381
229
 
382
- // Poll the token buffer from Node side
383
- let fullContent = "";
384
- const pollInterval = 100; // ms
385
- const deadline = Date.now() + timeoutMs;
386
-
387
- while (Date.now() < deadline) {
388
- const state = await page.evaluate(() => {
389
- const w = window as unknown as Record<string, unknown>;
390
- const tokens = (w.__grokTokenBuf as string[]).splice(0);
391
- return {
392
- tokens,
393
- done: w.__grokDone as boolean,
394
- error: w.__grokError as string | null,
395
- meta: w.__grokMeta as { inputTokenCount?: number; outputTokenCount?: number } | null,
396
- };
397
- });
398
-
399
- for (const token of state.tokens) {
400
- onToken(token);
401
- fullContent += token;
402
- }
403
-
404
- if (state.error) {
405
- throw new Error(`grok.com stream error: ${state.error}`);
406
- }
230
+ if (text && text.length > emittedLength) {
231
+ const newChars = text.slice(emittedLength);
232
+ onToken(newChars);
233
+ emittedLength = text.length;
234
+ }
407
235
 
408
- if (state.done) {
409
- log(
410
- `grok-client: stream done ${state.meta?.outputTokenCount ?? "?"} tokens`
411
- );
412
- return {
413
- content: fullContent,
414
- model,
415
- finishReason: "stop",
416
- promptTokens: state.meta?.inputTokenCount,
417
- completionTokens: state.meta?.outputTokenCount,
418
- };
236
+ if (text && text === lastText) {
237
+ stableCount++;
238
+ if (stableCount >= STABLE_CHECKS) {
239
+ log(`grok-client: stream done (${text.length} chars)`);
240
+ return { content: text, model, finishReason: "stop" };
419
241
  }
420
-
421
- await new Promise((r) => setTimeout(r, pollInterval));
242
+ } else {
243
+ stableCount = 0;
244
+ lastText = text;
422
245
  }
423
-
424
- throw new Error(`grok.com stream timeout after ${timeoutMs}ms`);
425
- } finally {
426
- await page.close();
427
246
  }
247
+
248
+ throw new Error(`grok.com stream timeout after ${timeoutMs}ms`);
428
249
  }
@@ -87,51 +87,51 @@ export async function verifySession(
87
87
  context: BrowserContext,
88
88
  log: (msg: string) => void
89
89
  ): Promise<SessionCheckResult> {
90
+ log("verifying grok session...");
91
+
92
+ // Prefer an existing grok.com page — don't open a new one (new pages can
93
+ // trigger Cloudflare checks and displace the authenticated session page).
94
+ const existingPages = context.pages().filter((p) => p.url().startsWith("https://grok.com"));
95
+ if (existingPages.length > 0) {
96
+ const page = existingPages[0];
97
+ // Check for sign-in link on existing page
98
+ const signIn = page.locator('a[href*="sign-in"], a[href*="/login"]');
99
+ const signInVisible = await signIn.isVisible().catch(() => false);
100
+ if (signInVisible) return { valid: false, reason: "sign-in link visible — session expired" };
101
+ // Check for editor (logged in indicator)
102
+ const editor = page.locator('.ProseMirror, [contenteditable="true"]');
103
+ const editorVisible = await editor.isVisible().catch(() => false);
104
+ if (editorVisible) {
105
+ log("session valid ✅");
106
+ return { valid: true };
107
+ }
108
+ }
109
+
110
+ // No existing page — open one to check, then leave it open for reuse
90
111
  const page = await context.newPage();
91
112
  try {
92
113
  log("verifying grok session...");
93
114
  await page.goto(GROK_HOME, { waitUntil: "domcontentloaded", timeout: 15_000 });
94
115
 
95
- // If we see Sign In link → not logged in
96
116
  const signIn = page.locator('a[href*="sign-in"], a[href*="/login"]');
97
- const signInVisible = await signIn.isVisible().catch(() => false);
98
- if (signInVisible) {
117
+ if (await signIn.isVisible().catch(() => false)) {
118
+ await page.close();
99
119
  return { valid: false, reason: "sign-in link visible — session expired" };
100
120
  }
101
121
 
102
- // If we see the chat input → logged in
103
- const chatInput = page.locator('textarea, [placeholder*="mind"], [aria-label*="message"]');
104
- const chatVisible = await chatInput.isVisible().catch(() => false);
105
- if (chatVisible) {
122
+ const editor = page.locator('.ProseMirror, [contenteditable="true"]');
123
+ if (await editor.isVisible().catch(() => false)) {
124
+ // Keep page open grokComplete will reuse it
106
125
  log("session valid ✅");
107
126
  return { valid: true };
108
127
  }
109
128
 
110
- // Ambiguous — try API endpoint
111
- const resp = await page.evaluate(async () => {
112
- try {
113
- const r = await fetch("https://grok.com/rest/app-chat/conversations", {
114
- method: "GET",
115
- credentials: "include",
116
- });
117
- return { status: r.status };
118
- } catch (e: unknown) {
119
- return { status: 0, error: String(e) };
120
- }
121
- });
122
-
123
- if (resp.status === 200 || resp.status === 204) {
124
- log("session valid via API check ✅");
125
- return { valid: true };
126
- }
127
- if (resp.status === 401 || resp.status === 403) {
128
- return { valid: false, reason: `API returned ${resp.status}` };
129
- }
130
-
131
- log(`session check ambiguous (status ${resp.status}) — assuming valid`);
129
+ // Ambiguous — assume valid, keep page open
130
+ log("session check ambiguous assuming valid");
132
131
  return { valid: true };
133
- } finally {
134
- await page.close();
132
+ } catch (err) {
133
+ await page.close().catch(() => {});
134
+ return { valid: false, reason: (err as Error).message };
135
135
  }
136
136
  }
137
137
 
@@ -27,6 +27,8 @@ export interface ProxyServerOptions {
27
27
  warn: (msg: string) => void;
28
28
  /** Returns the current authenticated Grok BrowserContext (null if not logged in) */
29
29
  getGrokContext?: () => BrowserContext | null;
30
+ /** Async lazy connect — called when getGrokContext returns null */
31
+ connectGrokContext?: () => Promise<BrowserContext | null>;
30
32
  /** Override for testing — replaces grokComplete */
31
33
  _grokComplete?: typeof grokComplete;
32
34
  /** Override for testing — replaces grokCompleteStream */
@@ -198,7 +200,11 @@ async function handleRequest(
198
200
 
199
201
  // ── Grok web-session routing ──────────────────────────────────────────────
200
202
  if (model.startsWith("web-grok/")) {
201
- const grokCtx = opts.getGrokContext?.() ?? null;
203
+ let grokCtx = opts.getGrokContext?.() ?? null;
204
+ // Lazy connect: if context is null but a connector is provided, try now
205
+ if (!grokCtx && opts.connectGrokContext) {
206
+ grokCtx = await opts.connectGrokContext();
207
+ }
202
208
  if (!grokCtx) {
203
209
  res.writeHead(503, { "Content-Type": "application/json" });
204
210
  res.end(JSON.stringify({ error: { message: "No active grok.com session. Use /grok-login to authenticate.", code: "no_grok_session" } }));