@elvatis_com/openclaw-cli-bridge-elvatis 0.2.25 → 0.2.27
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/.ai/handoff/STATUS.md +5 -5
- package/README.md +15 -1
- package/SKILL.md +1 -1
- package/index.ts +207 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +4 -1
- package/src/grok-client.ts +249 -0
- package/src/grok-session.ts +195 -0
- package/src/proxy-server.ts +74 -3
- package/test/grok-proxy.test.ts +301 -0
- package/test/grok-session.test.ts +133 -0
package/.ai/handoff/STATUS.md
CHANGED
|
@@ -11,10 +11,10 @@ _Last session: 2026-03-11 — Akido (claude-sonnet-4-6)_
|
|
|
11
11
|
|
|
12
12
|
| Platform | Version | Status |
|
|
13
13
|
|----------|---------|--------|
|
|
14
|
-
| GitHub | v0.2.
|
|
15
|
-
| npm | 0.2.
|
|
16
|
-
| ClawHub | 0.2.
|
|
17
|
-
| Local | 0.2.25 |
|
|
14
|
+
| GitHub | v0.2.25 | ✅ Tagged + Release |
|
|
15
|
+
| npm | 0.2.25 | ✅ Published |
|
|
16
|
+
| ClawHub | 0.2.25 | ✅ Published (direct API — clawhub CLI v0.7.0 bug: missing acceptLicenseTerms) |
|
|
17
|
+
| Local | 0.2.25 | ✅ Up to date |
|
|
18
18
|
<!-- /SECTION: version -->
|
|
19
19
|
|
|
20
20
|
<!-- SECTION: build_health -->
|
|
@@ -55,7 +55,7 @@ _Last session: 2026-03-11 — Akido (claude-sonnet-4-6)_
|
|
|
55
55
|
<!-- SECTION: what_is_missing -->
|
|
56
56
|
## What Is Missing / Open
|
|
57
57
|
|
|
58
|
-
-
|
|
58
|
+
- ✅ **v0.2.25 published** — GitHub, npm, ClawHub alle auf 0.2.25
|
|
59
59
|
- ℹ️ **Claude CLI auth expires ~90 days** — when `/cli-test` returns 401, run `claude auth login`
|
|
60
60
|
- ℹ️ **Config patcher writes `openclaw.json` directly** — triggers one gateway restart on first install
|
|
61
61
|
- ℹ️ **ClawHub publish ignores `.clawhubignore`** — use rsync workaround (see CONVENTIONS.md)
|
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.27`
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -287,6 +287,20 @@ npm test # vitest run (45 tests)
|
|
|
287
287
|
|
|
288
288
|
## Changelog
|
|
289
289
|
|
|
290
|
+
### v0.2.27
|
|
291
|
+
- **feat:** Grok persistent Chromium profile (`~/.openclaw/grok-profile/`) — cookies survive gateway restarts
|
|
292
|
+
- **feat:** `/grok-login` imports cookies from OpenClaw browser into persistent profile automatically
|
|
293
|
+
- **fix:** `verifySession` reuses existing grok.com page instead of opening a new one (avoids Cloudflare 403)
|
|
294
|
+
- **fix:** DOM-polling strategy instead of direct fetch API — bypasses `x-statsig-id` anti-bot check completely
|
|
295
|
+
- **fix:** Lazy-connect: `connectGrokContext` callback auto-reconnects on first request after restart
|
|
296
|
+
|
|
297
|
+
### v0.2.26
|
|
298
|
+
- **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)
|
|
299
|
+
- **feat:** `/grok-login` — opens Chromium for X.com OAuth login, saves session to `~/.openclaw/grok-session.json`
|
|
300
|
+
- **feat:** `/grok-status` — check session validity
|
|
301
|
+
- **feat:** `/grok-logout` — clear session
|
|
302
|
+
- **fix:** Grok web-session plugin removed as separate plugin — consolidated into cli-bridge (fewer running processes, single proxy port)
|
|
303
|
+
|
|
290
304
|
### v0.2.25
|
|
291
305
|
- **feat:** Staged model switching — `/cli-*` now stages the switch instead of applying it immediately. Prevents silent session corruption when switching models mid-conversation.
|
|
292
306
|
- `/cli-sonnet` → stages switch, shows warning, does NOT apply
|
package/SKILL.md
CHANGED
package/index.ts
CHANGED
|
@@ -48,6 +48,16 @@ import {
|
|
|
48
48
|
} from "./src/codex-auth.js";
|
|
49
49
|
import { startProxyServer } from "./src/proxy-server.js";
|
|
50
50
|
import { patchOpencllawConfig } from "./src/config-patcher.js";
|
|
51
|
+
import {
|
|
52
|
+
loadSession,
|
|
53
|
+
deleteSession,
|
|
54
|
+
isSessionExpiredByAge,
|
|
55
|
+
verifySession,
|
|
56
|
+
runInteractiveLogin,
|
|
57
|
+
createContextFromSession,
|
|
58
|
+
DEFAULT_SESSION_PATH,
|
|
59
|
+
} from "./src/grok-session.js";
|
|
60
|
+
import type { BrowserContext, Browser } from "playwright";
|
|
51
61
|
|
|
52
62
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
53
63
|
// Types derived from SDK (not re-exported by the package)
|
|
@@ -66,6 +76,95 @@ interface CliPluginConfig {
|
|
|
66
76
|
proxyPort?: number;
|
|
67
77
|
proxyApiKey?: string;
|
|
68
78
|
proxyTimeoutMs?: number;
|
|
79
|
+
grokSessionPath?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
83
|
+
// Grok web-session state (module-level, persists across commands)
|
|
84
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
let grokBrowser: Browser | null = null;
|
|
87
|
+
let grokContext: BrowserContext | null = null;
|
|
88
|
+
|
|
89
|
+
// Persistent profile dir — survives gateway restarts, keeps cookies intact
|
|
90
|
+
const GROK_PROFILE_DIR = join(homedir(), ".openclaw", "grok-profile");
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Launch (or reuse) a persistent headless Chromium context for grok.com.
|
|
94
|
+
* Uses launchPersistentContext so cookies survive gateway restarts.
|
|
95
|
+
* The profile lives at ~/.openclaw/grok-profile/
|
|
96
|
+
*/
|
|
97
|
+
async function getOrLaunchGrokContext(
|
|
98
|
+
log: (msg: string) => void
|
|
99
|
+
): Promise<BrowserContext | null> {
|
|
100
|
+
// Already have a live context?
|
|
101
|
+
if (grokContext) {
|
|
102
|
+
try {
|
|
103
|
+
// Quick check: can we still enumerate pages?
|
|
104
|
+
grokContext.pages();
|
|
105
|
+
return grokContext;
|
|
106
|
+
} catch {
|
|
107
|
+
grokContext = null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const { chromium } = await import("playwright");
|
|
112
|
+
|
|
113
|
+
// 1. Try connecting to the OpenClaw managed browser first (user may have grok.com open)
|
|
114
|
+
try {
|
|
115
|
+
const browser = await chromium.connectOverCDP("http://127.0.0.1:18800", { timeout: 2000 });
|
|
116
|
+
grokBrowser = browser;
|
|
117
|
+
const ctx = browser.contexts()[0];
|
|
118
|
+
if (ctx) {
|
|
119
|
+
log("[cli-bridge:grok] connected to OpenClaw browser");
|
|
120
|
+
return ctx;
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
// OpenClaw browser not available — fall through to persistent context
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 2. Launch our own persistent headless Chromium with saved profile
|
|
127
|
+
log("[cli-bridge:grok] launching persistent Chromium…");
|
|
128
|
+
try {
|
|
129
|
+
const ctx = await chromium.launchPersistentContext(GROK_PROFILE_DIR, {
|
|
130
|
+
headless: true,
|
|
131
|
+
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
|
132
|
+
});
|
|
133
|
+
grokContext = ctx;
|
|
134
|
+
log("[cli-bridge:grok] persistent context ready");
|
|
135
|
+
return ctx;
|
|
136
|
+
} catch (err) {
|
|
137
|
+
log(`[cli-bridge:grok] failed to launch browser: ${(err as Error).message}`);
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function connectToOpenClawBrowser(
|
|
143
|
+
log: (msg: string) => void
|
|
144
|
+
): Promise<BrowserContext | null> {
|
|
145
|
+
return getOrLaunchGrokContext(log);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function tryRestoreGrokSession(
|
|
149
|
+
_sessionPath: string,
|
|
150
|
+
log: (msg: string) => void
|
|
151
|
+
): Promise<boolean> {
|
|
152
|
+
try {
|
|
153
|
+
const ctx = await getOrLaunchGrokContext(log);
|
|
154
|
+
if (!ctx) return false;
|
|
155
|
+
|
|
156
|
+
const check = await verifySession(ctx, log);
|
|
157
|
+
if (!check.valid) {
|
|
158
|
+
log(`[cli-bridge:grok] session invalid: ${check.reason}`);
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
grokContext = ctx;
|
|
162
|
+
log("[cli-bridge:grok] session restored ✅");
|
|
163
|
+
return true;
|
|
164
|
+
} catch (err) {
|
|
165
|
+
log(`[cli-bridge:grok] session restore error: ${(err as Error).message}`);
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
69
168
|
}
|
|
70
169
|
|
|
71
170
|
const DEFAULT_PROXY_PORT = 31337;
|
|
@@ -393,7 +492,7 @@ function proxyTestRequest(
|
|
|
393
492
|
const plugin = {
|
|
394
493
|
id: "openclaw-cli-bridge-elvatis",
|
|
395
494
|
name: "OpenClaw CLI Bridge",
|
|
396
|
-
version: "0.2.
|
|
495
|
+
version: "0.2.27",
|
|
397
496
|
description:
|
|
398
497
|
"Phase 1: openai-codex auth bridge. " +
|
|
399
498
|
"Phase 2: HTTP proxy for gemini/claude CLIs. " +
|
|
@@ -407,6 +506,10 @@ const plugin = {
|
|
|
407
506
|
const apiKey = cfg.proxyApiKey ?? DEFAULT_PROXY_API_KEY;
|
|
408
507
|
const timeoutMs = cfg.proxyTimeoutMs ?? 120_000;
|
|
409
508
|
const codexAuthPath = cfg.codexAuthPath ?? DEFAULT_CODEX_AUTH_PATH;
|
|
509
|
+
const grokSessionPath = cfg.grokSessionPath ?? DEFAULT_SESSION_PATH;
|
|
510
|
+
|
|
511
|
+
// ── Grok session restore (non-blocking) ───────────────────────────────────
|
|
512
|
+
void tryRestoreGrokSession(grokSessionPath, (msg) => api.logger.info(msg));
|
|
410
513
|
|
|
411
514
|
// ── Phase 1: openai-codex auth bridge ─────────────────────────────────────
|
|
412
515
|
if (enableCodex) {
|
|
@@ -503,6 +606,15 @@ const plugin = {
|
|
|
503
606
|
timeoutMs,
|
|
504
607
|
log: (msg) => api.logger.info(msg),
|
|
505
608
|
warn: (msg) => api.logger.warn(msg),
|
|
609
|
+
getGrokContext: () => grokContext,
|
|
610
|
+
connectGrokContext: async () => {
|
|
611
|
+
const ctx = await connectToOpenClawBrowser((msg) => api.logger.info(msg));
|
|
612
|
+
if (ctx) {
|
|
613
|
+
const check = await verifySession(ctx, (msg) => api.logger.info(msg));
|
|
614
|
+
if (check.valid) { grokContext = ctx; return ctx; }
|
|
615
|
+
}
|
|
616
|
+
return null;
|
|
617
|
+
},
|
|
506
618
|
});
|
|
507
619
|
proxyServer = server;
|
|
508
620
|
api.logger.info(
|
|
@@ -526,6 +638,15 @@ const plugin = {
|
|
|
526
638
|
port, apiKey, timeoutMs,
|
|
527
639
|
log: (msg) => api.logger.info(msg),
|
|
528
640
|
warn: (msg) => api.logger.warn(msg),
|
|
641
|
+
getGrokContext: () => grokContext,
|
|
642
|
+
connectGrokContext: async () => {
|
|
643
|
+
const ctx = await connectToOpenClawBrowser((msg) => api.logger.info(msg));
|
|
644
|
+
if (ctx) {
|
|
645
|
+
const check = await verifySession(ctx, (msg) => api.logger.info(msg));
|
|
646
|
+
if (check.valid) { grokContext = ctx; return ctx; }
|
|
647
|
+
}
|
|
648
|
+
return null;
|
|
649
|
+
},
|
|
529
650
|
});
|
|
530
651
|
proxyServer = server;
|
|
531
652
|
api.logger.info(`[cli-bridge] proxy ready on :${port} (retry)`);
|
|
@@ -771,11 +892,96 @@ const plugin = {
|
|
|
771
892
|
},
|
|
772
893
|
} satisfies OpenClawPluginCommandDefinition);
|
|
773
894
|
|
|
895
|
+
// ── Phase 4: Grok web-session commands ────────────────────────────────────
|
|
896
|
+
|
|
897
|
+
api.registerCommand({
|
|
898
|
+
name: "grok-login",
|
|
899
|
+
description: "Authenticate grok.com: imports cookies from OpenClaw browser into persistent profile",
|
|
900
|
+
handler: async (): Promise<PluginCommandResult> => {
|
|
901
|
+
if (grokContext) {
|
|
902
|
+
const check = await verifySession(grokContext, (msg) => api.logger.info(msg));
|
|
903
|
+
if (check.valid) {
|
|
904
|
+
return { text: "✅ Already connected to grok.com. Use `/grok-logout` first to reset." };
|
|
905
|
+
}
|
|
906
|
+
grokContext = null;
|
|
907
|
+
}
|
|
908
|
+
api.logger.info("[cli-bridge:grok] /grok-login: importing session from OpenClaw browser…");
|
|
909
|
+
const { chromium } = await import("playwright");
|
|
910
|
+
|
|
911
|
+
// Step 1: try to grab cookies from the OpenClaw browser (user must have grok.com open)
|
|
912
|
+
let importedCookies: unknown[] = [];
|
|
913
|
+
try {
|
|
914
|
+
const ocBrowser = await chromium.connectOverCDP("http://127.0.0.1:18800", { timeout: 3000 });
|
|
915
|
+
const ocCtx = ocBrowser.contexts()[0];
|
|
916
|
+
if (ocCtx) {
|
|
917
|
+
importedCookies = await ocCtx.cookies(["https://grok.com", "https://x.ai", "https://accounts.x.ai"]);
|
|
918
|
+
api.logger.info(`[cli-bridge:grok] imported ${importedCookies.length} cookies from OpenClaw browser`);
|
|
919
|
+
}
|
|
920
|
+
await ocBrowser.close().catch(() => {});
|
|
921
|
+
} catch {
|
|
922
|
+
api.logger.info("[cli-bridge:grok] OpenClaw browser not available — using saved profile");
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// Step 2: launch/connect persistent context and inject cookies
|
|
926
|
+
const ctx = await getOrLaunchGrokContext((msg) => api.logger.info(msg));
|
|
927
|
+
if (!ctx) return { text: "❌ Could not launch browser. Check server logs." };
|
|
928
|
+
|
|
929
|
+
if (importedCookies.length > 0) {
|
|
930
|
+
await ctx.addCookies(importedCookies as Parameters<typeof ctx.addCookies>[0]);
|
|
931
|
+
api.logger.info(`[cli-bridge:grok] cookies injected into persistent profile`);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// Step 3: navigate to grok.com and verify
|
|
935
|
+
const pages = ctx.pages();
|
|
936
|
+
const page = pages.find(p => p.url().includes("grok.com")) ?? await ctx.newPage();
|
|
937
|
+
if (!page.url().includes("grok.com")) {
|
|
938
|
+
await page.goto("https://grok.com", { waitUntil: "domcontentloaded", timeout: 15_000 });
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const check = await verifySession(ctx, (msg) => api.logger.info(msg));
|
|
942
|
+
if (!check.valid) {
|
|
943
|
+
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.` };
|
|
944
|
+
}
|
|
945
|
+
grokContext = ctx;
|
|
946
|
+
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\`\n\nSession persists across gateway restarts (profile: ~/.openclaw/grok-profile/)` };
|
|
947
|
+
},
|
|
948
|
+
} satisfies OpenClawPluginCommandDefinition);
|
|
949
|
+
|
|
950
|
+
api.registerCommand({
|
|
951
|
+
name: "grok-status",
|
|
952
|
+
description: "Check grok.com session status",
|
|
953
|
+
handler: async (): Promise<PluginCommandResult> => {
|
|
954
|
+
if (!grokContext) {
|
|
955
|
+
return { text: "❌ No active grok.com session\nRun `/grok-login` to authenticate." };
|
|
956
|
+
}
|
|
957
|
+
const check = await verifySession(grokContext, (msg) => api.logger.info(msg));
|
|
958
|
+
if (check.valid) {
|
|
959
|
+
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` };
|
|
960
|
+
}
|
|
961
|
+
grokContext = null;
|
|
962
|
+
return { text: `❌ Session expired: ${check.reason}\nRun \`/grok-login\` to re-authenticate.` };
|
|
963
|
+
},
|
|
964
|
+
} satisfies OpenClawPluginCommandDefinition);
|
|
965
|
+
|
|
966
|
+
api.registerCommand({
|
|
967
|
+
name: "grok-logout",
|
|
968
|
+
description: "Disconnect from grok.com session (does not close the browser)",
|
|
969
|
+
handler: async (): Promise<PluginCommandResult> => {
|
|
970
|
+
// Don't close the context — it belongs to the OpenClaw browser, not us
|
|
971
|
+
grokContext = null;
|
|
972
|
+
deleteSession(grokSessionPath);
|
|
973
|
+
return { text: "✅ Disconnected from grok.com. Run `/grok-login` to reconnect." };
|
|
974
|
+
},
|
|
975
|
+
} satisfies OpenClawPluginCommandDefinition);
|
|
976
|
+
|
|
774
977
|
const allCommands = [
|
|
775
978
|
...CLI_MODEL_COMMANDS.map((c) => `/${c.name}`),
|
|
776
979
|
"/cli-back",
|
|
777
980
|
"/cli-test",
|
|
778
981
|
"/cli-list",
|
|
982
|
+
"/grok-login",
|
|
983
|
+
"/grok-status",
|
|
984
|
+
"/grok-logout",
|
|
779
985
|
];
|
|
780
986
|
api.logger.info(`[cli-bridge] registered ${allCommands.length} commands: ${allCommands.join(", ")}`);
|
|
781
987
|
},
|
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.27",
|
|
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.27",
|
|
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": {
|
|
@@ -18,5 +18,8 @@
|
|
|
18
18
|
"@types/node": "^25.3.2",
|
|
19
19
|
"typescript": "^5.9.3",
|
|
20
20
|
"vitest": "^4.0.18"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"playwright": "^1.58.2"
|
|
21
24
|
}
|
|
22
25
|
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* grok-client.ts
|
|
3
|
+
*
|
|
4
|
+
* Grok.com integration via Playwright DOM automation.
|
|
5
|
+
*
|
|
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).
|
|
10
|
+
*
|
|
11
|
+
* Works by connecting to the running OpenClaw browser (CDP port 18800) which
|
|
12
|
+
* already has an authenticated grok.com session open.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { BrowserContext, Page } from "playwright";
|
|
16
|
+
|
|
17
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
18
|
+
// Types
|
|
19
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export interface ChatMessage {
|
|
22
|
+
role: "system" | "user" | "assistant";
|
|
23
|
+
content: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface GrokCompleteOptions {
|
|
27
|
+
messages: ChatMessage[];
|
|
28
|
+
model?: string;
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface GrokCompleteResult {
|
|
33
|
+
content: string;
|
|
34
|
+
model: string;
|
|
35
|
+
finishReason: string;
|
|
36
|
+
promptTokens?: number;
|
|
37
|
+
completionTokens?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
41
|
+
// Constants
|
|
42
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
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
|
|
47
|
+
|
|
48
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
49
|
+
// Helpers
|
|
50
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
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
|
+
}
|
|
57
|
+
|
|
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
|
+
}
|
|
71
|
+
|
|
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 };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
86
|
+
// Core DOM automation
|
|
87
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
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
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
throw new Error(`grok.com response timeout after ${timeoutMs}ms`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
152
|
+
// Public API
|
|
153
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Non-streaming completion.
|
|
157
|
+
*/
|
|
158
|
+
export async function grokComplete(
|
|
159
|
+
context: BrowserContext,
|
|
160
|
+
opts: GrokCompleteOptions,
|
|
161
|
+
log: (msg: string) => void
|
|
162
|
+
): Promise<GrokCompleteResult> {
|
|
163
|
+
const { page, owned } = await getOrCreateGrokPage(context);
|
|
164
|
+
const model = resolveModel(opts.model);
|
|
165
|
+
const prompt = flattenMessages(opts.messages);
|
|
166
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
167
|
+
|
|
168
|
+
log(`grok-client: complete model=${model}`);
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const content = await sendAndWait(page, prompt, timeoutMs, log);
|
|
172
|
+
return { content, model, finishReason: "stop" };
|
|
173
|
+
} finally {
|
|
174
|
+
if (owned) await page.close().catch(() => {});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Streaming completion — polls the DOM and calls onToken when new text arrives.
|
|
180
|
+
*/
|
|
181
|
+
export async function grokCompleteStream(
|
|
182
|
+
context: BrowserContext,
|
|
183
|
+
opts: GrokCompleteOptions,
|
|
184
|
+
onToken: (token: string) => void,
|
|
185
|
+
log: (msg: string) => void
|
|
186
|
+
): Promise<GrokCompleteResult> {
|
|
187
|
+
const { page, owned } = await getOrCreateGrokPage(context);
|
|
188
|
+
const model = resolveModel(opts.model);
|
|
189
|
+
const prompt = flattenMessages(opts.messages);
|
|
190
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
191
|
+
|
|
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() ?? "";
|
|
226
|
+
},
|
|
227
|
+
countBefore
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
if (text && text.length > emittedLength) {
|
|
231
|
+
const newChars = text.slice(emittedLength);
|
|
232
|
+
onToken(newChars);
|
|
233
|
+
emittedLength = text.length;
|
|
234
|
+
}
|
|
235
|
+
|
|
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" };
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
stableCount = 0;
|
|
244
|
+
lastText = text;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
throw new Error(`grok.com stream timeout after ${timeoutMs}ms`);
|
|
249
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* grok-session.ts
|
|
3
|
+
*
|
|
4
|
+
* Manages a persistent grok.com browser session using Playwright.
|
|
5
|
+
*
|
|
6
|
+
* Auth flow:
|
|
7
|
+
* 1. First run: open Chromium, navigate to grok.com → user logs in manually via X.com OAuth
|
|
8
|
+
* 2. On success: save cookies + localStorage to SESSION_PATH
|
|
9
|
+
* 3. Subsequent runs: restore session from file, verify still valid
|
|
10
|
+
* 4. If session expired: repeat step 1
|
|
11
|
+
*
|
|
12
|
+
* The saved session file is stored at ~/.openclaw/grok-session.json
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join, dirname } from "node:path";
|
|
18
|
+
import type { Browser, BrowserContext, Cookie } from "playwright";
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_SESSION_PATH = join(
|
|
21
|
+
homedir(),
|
|
22
|
+
".openclaw",
|
|
23
|
+
"grok-session.json"
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
export const GROK_HOME = "https://grok.com";
|
|
27
|
+
export const GROK_API_BASE = "https://grok.com/api";
|
|
28
|
+
|
|
29
|
+
/** Stored session data */
|
|
30
|
+
export interface GrokSession {
|
|
31
|
+
cookies: Cookie[];
|
|
32
|
+
savedAt: number; // epoch ms
|
|
33
|
+
userAgent?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Result of a session check */
|
|
37
|
+
export interface SessionCheckResult {
|
|
38
|
+
valid: boolean;
|
|
39
|
+
reason?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
// Persistence helpers
|
|
44
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export function loadSession(sessionPath: string): GrokSession | null {
|
|
47
|
+
if (!existsSync(sessionPath)) return null;
|
|
48
|
+
try {
|
|
49
|
+
const raw = readFileSync(sessionPath, "utf-8");
|
|
50
|
+
const parsed = JSON.parse(raw) as GrokSession;
|
|
51
|
+
if (!parsed.cookies || !Array.isArray(parsed.cookies)) return null;
|
|
52
|
+
return parsed;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function saveSession(
|
|
59
|
+
sessionPath: string,
|
|
60
|
+
session: GrokSession
|
|
61
|
+
): void {
|
|
62
|
+
mkdirSync(dirname(sessionPath), { recursive: true });
|
|
63
|
+
writeFileSync(sessionPath, JSON.stringify(session, null, 2), "utf-8");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function deleteSession(sessionPath: string): void {
|
|
67
|
+
if (existsSync(sessionPath)) {
|
|
68
|
+
unlinkSync(sessionPath);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
73
|
+
// Session validation
|
|
74
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
77
|
+
|
|
78
|
+
export function isSessionExpiredByAge(session: GrokSession): boolean {
|
|
79
|
+
return Date.now() - session.savedAt > SESSION_MAX_AGE_MS;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Verify the session is still valid by making a lightweight API call.
|
|
84
|
+
* Returns {valid: true} if the session works, {valid: false, reason} otherwise.
|
|
85
|
+
*/
|
|
86
|
+
export async function verifySession(
|
|
87
|
+
context: BrowserContext,
|
|
88
|
+
log: (msg: string) => void
|
|
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
|
|
111
|
+
const page = await context.newPage();
|
|
112
|
+
try {
|
|
113
|
+
log("verifying grok session...");
|
|
114
|
+
await page.goto(GROK_HOME, { waitUntil: "domcontentloaded", timeout: 15_000 });
|
|
115
|
+
|
|
116
|
+
const signIn = page.locator('a[href*="sign-in"], a[href*="/login"]');
|
|
117
|
+
if (await signIn.isVisible().catch(() => false)) {
|
|
118
|
+
await page.close();
|
|
119
|
+
return { valid: false, reason: "sign-in link visible — session expired" };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const editor = page.locator('.ProseMirror, [contenteditable="true"]');
|
|
123
|
+
if (await editor.isVisible().catch(() => false)) {
|
|
124
|
+
// Keep page open — grokComplete will reuse it
|
|
125
|
+
log("session valid ✅");
|
|
126
|
+
return { valid: true };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Ambiguous — assume valid, keep page open
|
|
130
|
+
log("session check ambiguous — assuming valid");
|
|
131
|
+
return { valid: true };
|
|
132
|
+
} catch (err) {
|
|
133
|
+
await page.close().catch(() => {});
|
|
134
|
+
return { valid: false, reason: (err as Error).message };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
139
|
+
// Interactive login (opens visible browser window)
|
|
140
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
export async function runInteractiveLogin(
|
|
143
|
+
browser: Browser,
|
|
144
|
+
sessionPath: string,
|
|
145
|
+
log: (msg: string) => void,
|
|
146
|
+
timeoutMs = 5 * 60 * 1000
|
|
147
|
+
): Promise<GrokSession> {
|
|
148
|
+
log("opening browser for grok.com login — please sign in with your X account...");
|
|
149
|
+
|
|
150
|
+
const context = await browser.newContext({
|
|
151
|
+
viewport: { width: 1280, height: 800 },
|
|
152
|
+
});
|
|
153
|
+
const page = await context.newPage();
|
|
154
|
+
|
|
155
|
+
await page.goto(GROK_HOME, { waitUntil: "domcontentloaded", timeout: 15_000 });
|
|
156
|
+
|
|
157
|
+
// Wait for sign-in to complete: look for chat textarea to appear
|
|
158
|
+
log(`waiting for login (timeout: ${timeoutMs / 1000}s)...`);
|
|
159
|
+
await page.waitForSelector(
|
|
160
|
+
'textarea, [placeholder*="mind"], [aria-label*="message"]',
|
|
161
|
+
{ timeout: timeoutMs }
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
log("login detected — saving session...");
|
|
165
|
+
const cookies = await context.cookies();
|
|
166
|
+
const userAgent = await page.evaluate(() => navigator.userAgent);
|
|
167
|
+
|
|
168
|
+
const session: GrokSession = {
|
|
169
|
+
cookies,
|
|
170
|
+
savedAt: Date.now(),
|
|
171
|
+
userAgent,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
saveSession(sessionPath, session);
|
|
175
|
+
log(`session saved to ${sessionPath}`);
|
|
176
|
+
|
|
177
|
+
await context.close();
|
|
178
|
+
return session;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
182
|
+
// Context factory: create a BrowserContext with restored cookies
|
|
183
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
export async function createContextFromSession(
|
|
186
|
+
browser: Browser,
|
|
187
|
+
session: GrokSession
|
|
188
|
+
): Promise<BrowserContext> {
|
|
189
|
+
const context = await browser.newContext({
|
|
190
|
+
userAgent: session.userAgent,
|
|
191
|
+
viewport: { width: 1280, height: 800 },
|
|
192
|
+
});
|
|
193
|
+
await context.addCookies(session.cookies);
|
|
194
|
+
return context;
|
|
195
|
+
}
|
package/src/proxy-server.ts
CHANGED
|
@@ -12,6 +12,12 @@ import http from "node:http";
|
|
|
12
12
|
import { randomBytes } from "node:crypto";
|
|
13
13
|
import { type ChatMessage, routeToCliRunner } from "./cli-runner.js";
|
|
14
14
|
import { scheduleTokenRefresh, setAuthLogger, stopTokenRefresh } from "./claude-auth.js";
|
|
15
|
+
import { grokComplete, grokCompleteStream, type ChatMessage as GrokChatMessage } from "./grok-client.js";
|
|
16
|
+
import type { BrowserContext } from "playwright";
|
|
17
|
+
|
|
18
|
+
export type GrokCompleteOptions = Parameters<typeof grokComplete>[1];
|
|
19
|
+
export type GrokCompleteStreamOptions = Parameters<typeof grokCompleteStream>[1];
|
|
20
|
+
export type GrokCompleteResult = Awaited<ReturnType<typeof grokComplete>>;
|
|
15
21
|
|
|
16
22
|
export interface ProxyServerOptions {
|
|
17
23
|
port: number;
|
|
@@ -19,6 +25,14 @@ export interface ProxyServerOptions {
|
|
|
19
25
|
timeoutMs?: number;
|
|
20
26
|
log: (msg: string) => void;
|
|
21
27
|
warn: (msg: string) => void;
|
|
28
|
+
/** Returns the current authenticated Grok BrowserContext (null if not logged in) */
|
|
29
|
+
getGrokContext?: () => BrowserContext | null;
|
|
30
|
+
/** Async lazy connect — called when getGrokContext returns null */
|
|
31
|
+
connectGrokContext?: () => Promise<BrowserContext | null>;
|
|
32
|
+
/** Override for testing — replaces grokComplete */
|
|
33
|
+
_grokComplete?: typeof grokComplete;
|
|
34
|
+
/** Override for testing — replaces grokCompleteStream */
|
|
35
|
+
_grokCompleteStream?: typeof grokCompleteStream;
|
|
22
36
|
}
|
|
23
37
|
|
|
24
38
|
/** Available CLI bridge models for GET /v1/models */
|
|
@@ -59,6 +73,11 @@ export const CLI_MODELS = [
|
|
|
59
73
|
contextWindow: 200_000,
|
|
60
74
|
maxTokens: 8192,
|
|
61
75
|
},
|
|
76
|
+
// Grok web-session models (requires /grok-login)
|
|
77
|
+
{ id: "web-grok/grok-3", name: "Grok 3 (web session)", contextWindow: 131_072, maxTokens: 131_072 },
|
|
78
|
+
{ id: "web-grok/grok-3-fast", name: "Grok 3 Fast (web session)", contextWindow: 131_072, maxTokens: 131_072 },
|
|
79
|
+
{ id: "web-grok/grok-3-mini", name: "Grok 3 Mini (web session)", contextWindow: 131_072, maxTokens: 131_072 },
|
|
80
|
+
{ id: "web-grok/grok-3-mini-fast", name: "Grok 3 Mini Fast (web session)", contextWindow: 131_072, maxTokens: 131_072 },
|
|
62
81
|
];
|
|
63
82
|
|
|
64
83
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
@@ -176,6 +195,61 @@ async function handleRequest(
|
|
|
176
195
|
|
|
177
196
|
opts.log(`[cli-bridge] ${model} · ${messages.length} msg(s) · stream=${stream}`);
|
|
178
197
|
|
|
198
|
+
const id = `chatcmpl-cli-${randomBytes(6).toString("hex")}`;
|
|
199
|
+
const created = Math.floor(Date.now() / 1000);
|
|
200
|
+
|
|
201
|
+
// ── Grok web-session routing ──────────────────────────────────────────────
|
|
202
|
+
if (model.startsWith("web-grok/")) {
|
|
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
|
+
}
|
|
208
|
+
if (!grokCtx) {
|
|
209
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
210
|
+
res.end(JSON.stringify({ error: { message: "No active grok.com session. Use /grok-login to authenticate.", code: "no_grok_session" } }));
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const grokModel = model.replace("web-grok/", "");
|
|
214
|
+
const timeoutMs = opts.timeoutMs ?? 120_000;
|
|
215
|
+
const grokMessages = messages as GrokChatMessage[];
|
|
216
|
+
const doGrokComplete = opts._grokComplete ?? grokComplete;
|
|
217
|
+
const doGrokCompleteStream = opts._grokCompleteStream ?? grokCompleteStream;
|
|
218
|
+
try {
|
|
219
|
+
if (stream) {
|
|
220
|
+
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", ...corsHeaders() });
|
|
221
|
+
sendSseChunk(res, { id, created, model, delta: { role: "assistant" }, finish_reason: null });
|
|
222
|
+
const result = await doGrokCompleteStream(
|
|
223
|
+
grokCtx,
|
|
224
|
+
{ messages: grokMessages, model: grokModel, timeoutMs },
|
|
225
|
+
(token) => sendSseChunk(res, { id, created, model, delta: { content: token }, finish_reason: null }),
|
|
226
|
+
opts.log
|
|
227
|
+
);
|
|
228
|
+
sendSseChunk(res, { id, created, model, delta: {}, finish_reason: result.finishReason });
|
|
229
|
+
res.write("data: [DONE]\n\n");
|
|
230
|
+
res.end();
|
|
231
|
+
} else {
|
|
232
|
+
const result = await doGrokComplete(grokCtx, { messages: grokMessages, model: grokModel, timeoutMs }, opts.log);
|
|
233
|
+
res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
|
|
234
|
+
res.end(JSON.stringify({
|
|
235
|
+
id, object: "chat.completion", created, model,
|
|
236
|
+
choices: [{ index: 0, message: { role: "assistant", content: result.content }, finish_reason: result.finishReason }],
|
|
237
|
+
usage: { prompt_tokens: result.promptTokens ?? 0, completion_tokens: result.completionTokens ?? 0, total_tokens: (result.promptTokens ?? 0) + (result.completionTokens ?? 0) },
|
|
238
|
+
}));
|
|
239
|
+
}
|
|
240
|
+
} catch (err) {
|
|
241
|
+
const msg = (err as Error).message;
|
|
242
|
+
opts.warn(`[cli-bridge] Grok error for ${model}: ${msg}`);
|
|
243
|
+
if (!res.headersSent) {
|
|
244
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
245
|
+
res.end(JSON.stringify({ error: { message: msg, type: "grok_error" } }));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
// ── CLI runner routing (Gemini / Claude Code) ─────────────────────────────
|
|
179
253
|
let content: string;
|
|
180
254
|
try {
|
|
181
255
|
content = await routeToCliRunner(model, messages, opts.timeoutMs ?? 120_000);
|
|
@@ -187,9 +261,6 @@ async function handleRequest(
|
|
|
187
261
|
return;
|
|
188
262
|
}
|
|
189
263
|
|
|
190
|
-
const id = `chatcmpl-cli-${randomBytes(6).toString("hex")}`;
|
|
191
|
-
const created = Math.floor(Date.now() / 1000);
|
|
192
|
-
|
|
193
264
|
if (stream) {
|
|
194
265
|
res.writeHead(200, {
|
|
195
266
|
"Content-Type": "text/event-stream",
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* test/grok-proxy.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Tests for Grok web-session routing integrated into the cli-bridge proxy.
|
|
5
|
+
* Uses _grokComplete/_grokCompleteStream overrides (DI) instead of vi.mock,
|
|
6
|
+
* which avoids ESM hoisting issues entirely.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
|
10
|
+
import http from "node:http";
|
|
11
|
+
import type { AddressInfo } from "node:net";
|
|
12
|
+
import { startProxyServer, CLI_MODELS } from "../src/proxy-server.js";
|
|
13
|
+
import type { BrowserContext } from "playwright";
|
|
14
|
+
import type { GrokCompleteOptions, GrokCompleteResult } from "../src/proxy-server.js";
|
|
15
|
+
|
|
16
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
// Stub implementations (no browser needed)
|
|
18
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const stubGrokComplete = vi.fn(async (
|
|
21
|
+
_ctx: BrowserContext,
|
|
22
|
+
opts: GrokCompleteOptions,
|
|
23
|
+
_log: (msg: string) => void
|
|
24
|
+
): Promise<GrokCompleteResult> => ({
|
|
25
|
+
content: `grok mock: ${opts.messages[opts.messages.length - 1]?.content ?? ""}`,
|
|
26
|
+
model: opts.model ?? "grok-3",
|
|
27
|
+
finishReason: "stop",
|
|
28
|
+
promptTokens: 8,
|
|
29
|
+
completionTokens: 4,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
const stubGrokCompleteStream = vi.fn(async (
|
|
33
|
+
_ctx: BrowserContext,
|
|
34
|
+
opts: GrokCompleteOptions,
|
|
35
|
+
onToken: (t: string) => void,
|
|
36
|
+
_log: (msg: string) => void
|
|
37
|
+
): Promise<GrokCompleteResult> => {
|
|
38
|
+
const tokens = ["grok ", "stream ", "mock"];
|
|
39
|
+
for (const t of tokens) onToken(t);
|
|
40
|
+
return {
|
|
41
|
+
content: tokens.join(""),
|
|
42
|
+
model: opts.model ?? "grok-3",
|
|
43
|
+
finishReason: "stop",
|
|
44
|
+
promptTokens: 8,
|
|
45
|
+
completionTokens: 3,
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
50
|
+
// Helpers
|
|
51
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
async function httpPost(
|
|
54
|
+
url: string,
|
|
55
|
+
body: unknown,
|
|
56
|
+
headers: Record<string, string> = {}
|
|
57
|
+
): Promise<{ status: number; body: unknown }> {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const data = JSON.stringify(body);
|
|
60
|
+
const urlObj = new URL(url);
|
|
61
|
+
const req = http.request(
|
|
62
|
+
{
|
|
63
|
+
hostname: urlObj.hostname,
|
|
64
|
+
port: parseInt(urlObj.port),
|
|
65
|
+
path: urlObj.pathname,
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: {
|
|
68
|
+
"Content-Type": "application/json",
|
|
69
|
+
"Content-Length": Buffer.byteLength(data),
|
|
70
|
+
...headers,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
(res) => {
|
|
74
|
+
let resp = "";
|
|
75
|
+
res.on("data", (c) => (resp += c));
|
|
76
|
+
res.on("end", () => {
|
|
77
|
+
try { resolve({ status: res.statusCode ?? 0, body: JSON.parse(resp) }); }
|
|
78
|
+
catch { resolve({ status: res.statusCode ?? 0, body: resp }); }
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
req.on("error", reject);
|
|
83
|
+
req.write(data);
|
|
84
|
+
req.end();
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function httpGet(
|
|
89
|
+
url: string,
|
|
90
|
+
headers: Record<string, string> = {}
|
|
91
|
+
): Promise<{ status: number; body: unknown }> {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const req = http.get(url, { headers }, (res) => {
|
|
94
|
+
let data = "";
|
|
95
|
+
res.on("data", (c) => (data += c));
|
|
96
|
+
res.on("end", () => {
|
|
97
|
+
try { resolve({ status: res.statusCode ?? 0, body: JSON.parse(data) }); }
|
|
98
|
+
catch { resolve({ status: res.statusCode ?? 0, body: data }); }
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
req.on("error", reject);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
106
|
+
// Setup: three servers
|
|
107
|
+
// - withCtx: has mock BrowserContext + stub overrides
|
|
108
|
+
// - noCtx: no BrowserContext (returns null)
|
|
109
|
+
// - noCtxNoOverride: no context, no overrides (tests 503)
|
|
110
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
const TEST_KEY = "test-grok-key";
|
|
113
|
+
const MOCK_CONTEXT = {} as BrowserContext;
|
|
114
|
+
|
|
115
|
+
let serverWithCtx: http.Server;
|
|
116
|
+
let serverNoCtx: http.Server;
|
|
117
|
+
let urlWith: string;
|
|
118
|
+
let urlNo: string;
|
|
119
|
+
|
|
120
|
+
beforeAll(async () => {
|
|
121
|
+
serverWithCtx = await startProxyServer({
|
|
122
|
+
port: 0,
|
|
123
|
+
apiKey: TEST_KEY,
|
|
124
|
+
log: () => {},
|
|
125
|
+
warn: () => {},
|
|
126
|
+
getGrokContext: () => MOCK_CONTEXT,
|
|
127
|
+
_grokComplete: stubGrokComplete,
|
|
128
|
+
_grokCompleteStream: stubGrokCompleteStream,
|
|
129
|
+
});
|
|
130
|
+
const addrWith = serverWithCtx.address() as AddressInfo;
|
|
131
|
+
urlWith = `http://127.0.0.1:${addrWith.port}`;
|
|
132
|
+
|
|
133
|
+
serverNoCtx = await startProxyServer({
|
|
134
|
+
port: 0,
|
|
135
|
+
apiKey: TEST_KEY,
|
|
136
|
+
log: () => {},
|
|
137
|
+
warn: () => {},
|
|
138
|
+
getGrokContext: () => null,
|
|
139
|
+
_grokComplete: stubGrokComplete,
|
|
140
|
+
_grokCompleteStream: stubGrokCompleteStream,
|
|
141
|
+
});
|
|
142
|
+
const addrNo = serverNoCtx.address() as AddressInfo;
|
|
143
|
+
urlNo = `http://127.0.0.1:${addrNo.port}`;
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
afterAll(async () => {
|
|
147
|
+
await new Promise<void>((r) => serverWithCtx.close(() => r()));
|
|
148
|
+
await new Promise<void>((r) => serverNoCtx.close(() => r()));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
152
|
+
// Tests
|
|
153
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
describe("GET /v1/models includes Grok web-session models", () => {
|
|
156
|
+
it("lists web-grok/* models", async () => {
|
|
157
|
+
const { status, body } = await httpGet(`${urlWith}/v1/models`, {
|
|
158
|
+
Authorization: `Bearer ${TEST_KEY}`,
|
|
159
|
+
});
|
|
160
|
+
expect(status).toBe(200);
|
|
161
|
+
const b = body as { data: Array<{ id: string }> };
|
|
162
|
+
const grokModels = b.data.filter((m) => m.id.startsWith("web-grok/"));
|
|
163
|
+
expect(grokModels.length).toBe(4);
|
|
164
|
+
expect(grokModels.map((m) => m.id)).toContain("web-grok/grok-3");
|
|
165
|
+
expect(grokModels.map((m) => m.id)).toContain("web-grok/grok-3-mini");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("CLI_MODELS exports 4 grok models", () => {
|
|
169
|
+
const grok = CLI_MODELS.filter((m) => m.id.startsWith("web-grok/"));
|
|
170
|
+
expect(grok).toHaveLength(4);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("POST /v1/chat/completions — Grok routing", () => {
|
|
175
|
+
const auth = { Authorization: `Bearer ${TEST_KEY}` };
|
|
176
|
+
|
|
177
|
+
it("returns 503 when no Grok session", async () => {
|
|
178
|
+
const { status, body } = await httpPost(
|
|
179
|
+
`${urlNo}/v1/chat/completions`,
|
|
180
|
+
{ model: "web-grok/grok-3", messages: [{ role: "user", content: "Hi" }] },
|
|
181
|
+
auth
|
|
182
|
+
);
|
|
183
|
+
expect(status).toBe(503);
|
|
184
|
+
const b = body as { error: { code: string } };
|
|
185
|
+
expect(b.error.code).toBe("no_grok_session");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("returns 200 with mock context (non-streaming)", async () => {
|
|
189
|
+
stubGrokComplete.mockClear();
|
|
190
|
+
const { status, body } = await httpPost(
|
|
191
|
+
`${urlWith}/v1/chat/completions`,
|
|
192
|
+
{ model: "web-grok/grok-3", messages: [{ role: "user", content: "Hello Grok" }], stream: false },
|
|
193
|
+
auth
|
|
194
|
+
);
|
|
195
|
+
expect(status).toBe(200);
|
|
196
|
+
const b = body as {
|
|
197
|
+
object: string;
|
|
198
|
+
model: string;
|
|
199
|
+
choices: Array<{ message: { content: string }; finish_reason: string }>;
|
|
200
|
+
usage: { prompt_tokens: number; completion_tokens: number };
|
|
201
|
+
};
|
|
202
|
+
expect(b.object).toBe("chat.completion");
|
|
203
|
+
expect(b.model).toBe("web-grok/grok-3");
|
|
204
|
+
expect(b.choices[0].message.content).toContain("Hello Grok");
|
|
205
|
+
expect(b.choices[0].finish_reason).toBe("stop");
|
|
206
|
+
expect(b.usage.prompt_tokens).toBe(8);
|
|
207
|
+
expect(b.usage.completion_tokens).toBe(4);
|
|
208
|
+
expect(stubGrokComplete).toHaveBeenCalledOnce();
|
|
209
|
+
// stub receives stripped model name
|
|
210
|
+
expect(stubGrokComplete.mock.calls[0][1].model).toBe("grok-3");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("strips web-grok/ prefix before passing to grokComplete", async () => {
|
|
214
|
+
stubGrokComplete.mockClear();
|
|
215
|
+
const { status, body } = await httpPost(
|
|
216
|
+
`${urlWith}/v1/chat/completions`,
|
|
217
|
+
{ model: "web-grok/grok-3-mini", messages: [{ role: "user", content: "test" }] },
|
|
218
|
+
auth
|
|
219
|
+
);
|
|
220
|
+
expect(status).toBe(200);
|
|
221
|
+
// response model should still have web-grok/ prefix
|
|
222
|
+
const b = body as { model: string };
|
|
223
|
+
expect(b.model).toBe("web-grok/grok-3-mini");
|
|
224
|
+
// stub receives stripped model
|
|
225
|
+
expect(stubGrokComplete.mock.calls[0][1].model).toBe("grok-3-mini");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("returns SSE stream for web-grok models", async () => {
|
|
229
|
+
return new Promise<void>((resolve, reject) => {
|
|
230
|
+
const data = JSON.stringify({
|
|
231
|
+
model: "web-grok/grok-3",
|
|
232
|
+
messages: [{ role: "user", content: "stream test" }],
|
|
233
|
+
stream: true,
|
|
234
|
+
});
|
|
235
|
+
const urlObj = new URL(`${urlWith}/v1/chat/completions`);
|
|
236
|
+
const req = http.request(
|
|
237
|
+
{
|
|
238
|
+
hostname: urlObj.hostname,
|
|
239
|
+
port: parseInt(urlObj.port),
|
|
240
|
+
path: urlObj.pathname,
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers: {
|
|
243
|
+
"Content-Type": "application/json",
|
|
244
|
+
"Content-Length": Buffer.byteLength(data),
|
|
245
|
+
Authorization: `Bearer ${TEST_KEY}`,
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
(res) => {
|
|
249
|
+
expect(res.statusCode).toBe(200);
|
|
250
|
+
expect(res.headers["content-type"]).toContain("text/event-stream");
|
|
251
|
+
let raw = "";
|
|
252
|
+
res.on("data", (c) => (raw += c));
|
|
253
|
+
res.on("end", () => {
|
|
254
|
+
const lines = raw.split("\n").filter((l) => l.startsWith("data: "));
|
|
255
|
+
expect(lines[lines.length - 1]).toBe("data: [DONE]");
|
|
256
|
+
const tokens = lines
|
|
257
|
+
.filter((l) => l !== "data: [DONE]")
|
|
258
|
+
.map((l) => { try { return JSON.parse(l.slice(6)); } catch { return null; } })
|
|
259
|
+
.filter(Boolean)
|
|
260
|
+
.flatMap((c) => c.choices?.[0]?.delta?.content ?? []);
|
|
261
|
+
expect(tokens.join("")).toBe("grok stream mock");
|
|
262
|
+
resolve();
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
);
|
|
266
|
+
req.on("error", reject);
|
|
267
|
+
req.write(data);
|
|
268
|
+
req.end();
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("non-web-grok models bypass Grok routing (go to CLI runner)", async () => {
|
|
273
|
+
stubGrokComplete.mockClear();
|
|
274
|
+
|
|
275
|
+
// Use a separate server with very short CLI timeout so this test finishes quickly
|
|
276
|
+
const fastSrv = await startProxyServer({
|
|
277
|
+
port: 0,
|
|
278
|
+
apiKey: TEST_KEY,
|
|
279
|
+
timeoutMs: 500, // fail fast — gemini CLI won't be available in test
|
|
280
|
+
log: () => {},
|
|
281
|
+
warn: () => {},
|
|
282
|
+
getGrokContext: () => MOCK_CONTEXT,
|
|
283
|
+
_grokComplete: stubGrokComplete,
|
|
284
|
+
_grokCompleteStream: stubGrokCompleteStream,
|
|
285
|
+
});
|
|
286
|
+
const addr = fastSrv.address() as AddressInfo;
|
|
287
|
+
const fastUrl = `http://127.0.0.1:${addr.port}`;
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
const { status } = await httpPost(
|
|
291
|
+
`${fastUrl}/v1/chat/completions`,
|
|
292
|
+
{ model: "cli-gemini/gemini-2.5-pro", messages: [{ role: "user", content: "test" }] },
|
|
293
|
+
auth
|
|
294
|
+
);
|
|
295
|
+
expect(status).not.toBe(503); // must NOT be "no_grok_session" — routing is different
|
|
296
|
+
expect(stubGrokComplete).not.toHaveBeenCalled(); // grokComplete never called for non-grok models
|
|
297
|
+
} finally {
|
|
298
|
+
await new Promise<void>((r) => fastSrv.close(() => r()));
|
|
299
|
+
}
|
|
300
|
+
}, 10_000);
|
|
301
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* test/session.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Unit tests for grok-session.ts — persistence, age check, cookie handling.
|
|
5
|
+
* No browser needed (mocked).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
9
|
+
import { existsSync, unlinkSync, readFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import {
|
|
13
|
+
loadSession,
|
|
14
|
+
saveSession,
|
|
15
|
+
isSessionExpiredByAge,
|
|
16
|
+
type GrokSession,
|
|
17
|
+
} from "../src/grok-session.js";
|
|
18
|
+
|
|
19
|
+
const TMP_SESSION = join(tmpdir(), `grok-test-session-${process.pid}.json`);
|
|
20
|
+
|
|
21
|
+
const MOCK_SESSION: GrokSession = {
|
|
22
|
+
cookies: [
|
|
23
|
+
{
|
|
24
|
+
name: "auth_token",
|
|
25
|
+
value: "test-token-123",
|
|
26
|
+
domain: ".grok.com",
|
|
27
|
+
path: "/",
|
|
28
|
+
expires: Date.now() / 1000 + 86400,
|
|
29
|
+
httpOnly: true,
|
|
30
|
+
secure: true,
|
|
31
|
+
sameSite: "Lax",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "ct0",
|
|
35
|
+
value: "csrf-token-abc",
|
|
36
|
+
domain: ".x.com",
|
|
37
|
+
path: "/",
|
|
38
|
+
expires: Date.now() / 1000 + 86400,
|
|
39
|
+
httpOnly: false,
|
|
40
|
+
secure: true,
|
|
41
|
+
sameSite: "Lax",
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
savedAt: Date.now(),
|
|
45
|
+
userAgent: "Mozilla/5.0 (test)",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
describe("grok-session persistence", () => {
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
if (existsSync(TMP_SESSION)) unlinkSync(TMP_SESSION);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns null when file does not exist", () => {
|
|
54
|
+
const result = loadSession(TMP_SESSION);
|
|
55
|
+
expect(result).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("saves and loads a session round-trip", () => {
|
|
59
|
+
saveSession(TMP_SESSION, MOCK_SESSION);
|
|
60
|
+
expect(existsSync(TMP_SESSION)).toBe(true);
|
|
61
|
+
|
|
62
|
+
const loaded = loadSession(TMP_SESSION);
|
|
63
|
+
expect(loaded).not.toBeNull();
|
|
64
|
+
expect(loaded!.cookies).toHaveLength(2);
|
|
65
|
+
expect(loaded!.cookies[0].name).toBe("auth_token");
|
|
66
|
+
expect(loaded!.cookies[0].value).toBe("test-token-123");
|
|
67
|
+
expect(loaded!.userAgent).toBe("Mozilla/5.0 (test)");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("returns null for corrupted JSON", () => {
|
|
71
|
+
const { writeFileSync } = require("node:fs");
|
|
72
|
+
writeFileSync(TMP_SESSION, "{ bad json }", "utf-8");
|
|
73
|
+
const result = loadSession(TMP_SESSION);
|
|
74
|
+
expect(result).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("returns null for missing cookies field", () => {
|
|
78
|
+
const { writeFileSync } = require("node:fs");
|
|
79
|
+
writeFileSync(TMP_SESSION, JSON.stringify({ savedAt: Date.now() }), "utf-8");
|
|
80
|
+
const result = loadSession(TMP_SESSION);
|
|
81
|
+
expect(result).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("returns null for cookies field not being array", () => {
|
|
85
|
+
const { writeFileSync } = require("node:fs");
|
|
86
|
+
writeFileSync(TMP_SESSION, JSON.stringify({ cookies: "not-array", savedAt: Date.now() }), "utf-8");
|
|
87
|
+
const result = loadSession(TMP_SESSION);
|
|
88
|
+
expect(result).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("saves valid JSON that can be parsed independently", () => {
|
|
92
|
+
saveSession(TMP_SESSION, MOCK_SESSION);
|
|
93
|
+
const raw = readFileSync(TMP_SESSION, "utf-8");
|
|
94
|
+
expect(() => JSON.parse(raw)).not.toThrow();
|
|
95
|
+
const parsed = JSON.parse(raw);
|
|
96
|
+
expect(parsed.savedAt).toBeTypeOf("number");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("overwrites existing session file", () => {
|
|
100
|
+
saveSession(TMP_SESSION, MOCK_SESSION);
|
|
101
|
+
|
|
102
|
+
const updated: GrokSession = { ...MOCK_SESSION, userAgent: "updated-ua", savedAt: Date.now() };
|
|
103
|
+
saveSession(TMP_SESSION, updated);
|
|
104
|
+
|
|
105
|
+
const loaded = loadSession(TMP_SESSION);
|
|
106
|
+
expect(loaded!.userAgent).toBe("updated-ua");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("session age check", () => {
|
|
111
|
+
it("fresh session is not expired", () => {
|
|
112
|
+
const session: GrokSession = { ...MOCK_SESSION, savedAt: Date.now() };
|
|
113
|
+
expect(isSessionExpiredByAge(session)).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("session saved 6 days ago is not expired", () => {
|
|
117
|
+
const sixDaysAgo = Date.now() - 6 * 24 * 60 * 60 * 1000;
|
|
118
|
+
const session: GrokSession = { ...MOCK_SESSION, savedAt: sixDaysAgo };
|
|
119
|
+
expect(isSessionExpiredByAge(session)).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("session saved 8 days ago is expired", () => {
|
|
123
|
+
const eightDaysAgo = Date.now() - 8 * 24 * 60 * 60 * 1000;
|
|
124
|
+
const session: GrokSession = { ...MOCK_SESSION, savedAt: eightDaysAgo };
|
|
125
|
+
expect(isSessionExpiredByAge(session)).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("session saved exactly 7 days ago is expired", () => {
|
|
129
|
+
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000 - 1;
|
|
130
|
+
const session: GrokSession = { ...MOCK_SESSION, savedAt: sevenDaysAgo };
|
|
131
|
+
expect(isSessionExpiredByAge(session)).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
});
|