@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 +14 -1
- package/SKILL.md +1 -1
- package/index.ts +192 -29
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/grok-client.ts +168 -347
- package/src/grok-session.ts +31 -31
- package/src/proxy-server.ts +7 -1
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.
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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]
|
|
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.
|
|
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: "
|
|
954
|
+
description: "Authenticate grok.com: imports cookies from OpenClaw browser into persistent profile",
|
|
835
955
|
handler: async (): Promise<PluginCommandResult> => {
|
|
836
956
|
if (grokContext) {
|
|
837
|
-
|
|
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]
|
|
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
|
-
|
|
842
|
-
const
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
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
|
-
|
|
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: "
|
|
1034
|
+
description: "Disconnect from grok.com session (does not close the browser)",
|
|
870
1035
|
handler: async (): Promise<PluginCommandResult> => {
|
|
871
|
-
|
|
872
|
-
|
|
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: "✅
|
|
1039
|
+
return { text: "✅ Disconnected from grok.com. Run `/grok-login` to reconnect." };
|
|
877
1040
|
},
|
|
878
1041
|
} satisfies OpenClawPluginCommandDefinition);
|
|
879
1042
|
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "openclaw-cli-bridge-elvatis",
|
|
3
3
|
"name": "OpenClaw CLI Bridge",
|
|
4
|
-
"version": "0.2.
|
|
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.
|
|
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": {
|
package/src/grok-client.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* grok-client.ts
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* using an authenticated browser session (cookies).
|
|
4
|
+
* Grok.com integration via Playwright DOM automation.
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
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;
|
|
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
|
-
//
|
|
41
|
+
// Constants
|
|
63
42
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
64
43
|
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
//
|
|
49
|
+
// Helpers
|
|
83
50
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
84
51
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
//
|
|
86
|
+
// Core DOM automation
|
|
129
87
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
130
88
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
|
163
|
+
const { page, owned } = await getOrCreateGrokPage(context);
|
|
159
164
|
const model = resolveModel(opts.model);
|
|
160
|
-
const
|
|
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
|
-
|
|
165
|
-
const page = await context.newPage();
|
|
168
|
+
log(`grok-client: complete model=${model}`);
|
|
166
169
|
|
|
167
170
|
try {
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
178
|
+
/**
|
|
179
|
+
* Streaming completion — polls 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
|
-
|
|
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
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
(
|
|
309
|
-
(
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
227
|
+
countBefore
|
|
380
228
|
);
|
|
381
229
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/grok-session.ts
CHANGED
|
@@ -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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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 —
|
|
111
|
-
|
|
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
|
-
}
|
|
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
|
|
package/src/proxy-server.ts
CHANGED
|
@@ -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
|
-
|
|
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" } }));
|