@elvatis_com/openclaw-cli-bridge-elvatis 1.6.5 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > OpenClaw plugin that bridges locally installed AI CLIs (Codex, Gemini, Claude Code) as model providers — with slash commands for instant model switching, restore, health testing, and model listing.
4
4
 
5
- **Current version:** `1.6.5`
5
+ **Current version:** `1.7.0`
6
6
 
7
7
  ---
8
8
 
@@ -362,6 +362,12 @@ npm test # vitest run (83 tests)
362
362
 
363
363
  ## Changelog
364
364
 
365
+ ### v1.7.0
366
+ - **fix:** Startup restore timeout 3s → 6s with one retry, eliminates false "not logged in" for slow-loading pages (Grok)
367
+ - **feat:** Auto-relogin on startup — if cookies truly expired, attempt headless relogin before sending WhatsApp alert
368
+ - **feat:** Keep-alive (20h) now verifies session after touch and attempts auto-relogin if expired
369
+ - **feat:** Tests (vitest) — proxy tool rejection, models endpoint, auth, cookie expiry formatters
370
+
365
371
  ### v1.6.5
366
372
  - **feat:** Automatic session keep-alive — every 20h, active browser sessions are silently refreshed by navigating to the provider home page. Prevents cookie expiry on providers like ChatGPT (7-day sessions) without storing credentials.
367
373
 
package/SKILL.md CHANGED
@@ -64,4 +64,4 @@ On gateway restart, if any session has expired, a **WhatsApp alert** is sent aut
64
64
 
65
65
  See `README.md` for full configuration reference and architecture diagram.
66
66
 
67
- **Version:** 1.6.5
67
+ **Version:** 1.7.0
package/index.ts CHANGED
@@ -56,6 +56,12 @@ import {
56
56
  createContextFromSession,
57
57
  DEFAULT_SESSION_PATH,
58
58
  } from "./src/grok-session.js";
59
+ import {
60
+ formatExpiryInfo,
61
+ formatGeminiExpiry,
62
+ formatClaudeExpiry,
63
+ formatChatGPTExpiry,
64
+ } from "./src/expiry-helpers.js";
59
65
  import type { BrowserContext, Browser } from "playwright";
60
66
 
61
67
  // ──────────────────────────────────────────────────────────────────────────────
@@ -120,14 +126,7 @@ function saveGeminiExpiry(info: GeminiExpiryInfo): void {
120
126
  function loadGeminiExpiry(): GeminiExpiryInfo | null {
121
127
  try { return JSON.parse(readFileSync(GEMINI_EXPIRY_FILE, "utf-8")) as GeminiExpiryInfo; } catch { return null; }
122
128
  }
123
- function formatGeminiExpiry(info: GeminiExpiryInfo): string {
124
- const daysLeft = Math.floor((info.expiresAt - Date.now()) / 86_400_000);
125
- const dateStr = new Date(info.expiresAt).toISOString().substring(0, 10);
126
- if (daysLeft < 0) return `⚠️ EXPIRED (${dateStr}) — run /gemini-login`;
127
- if (daysLeft <= 7) return `🚨 expires in ${daysLeft}d (${dateStr}) — run /gemini-login NOW`;
128
- if (daysLeft <= 14) return `⚠️ expires in ${daysLeft}d (${dateStr}) — run /gemini-login soon`;
129
- return `✅ valid for ${daysLeft} more days (expires ${dateStr})`;
130
- }
129
+ // formatGeminiExpiry imported from ./src/expiry-helpers.js
131
130
  async function scanGeminiCookieExpiry(ctx: BrowserContext): Promise<GeminiExpiryInfo | null> {
132
131
  try {
133
132
  const cookies = await ctx.cookies(["https://gemini.google.com", "https://accounts.google.com"]);
@@ -146,14 +145,7 @@ function saveClaudeExpiry(info: ClaudeExpiryInfo): void {
146
145
  function loadClaudeExpiry(): ClaudeExpiryInfo | null {
147
146
  try { return JSON.parse(readFileSync(CLAUDE_EXPIRY_FILE, "utf-8")) as ClaudeExpiryInfo; } catch { return null; }
148
147
  }
149
- function formatClaudeExpiry(info: ClaudeExpiryInfo): string {
150
- const daysLeft = Math.floor((info.expiresAt - Date.now()) / 86_400_000);
151
- const dateStr = new Date(info.expiresAt).toISOString().substring(0, 10);
152
- if (daysLeft < 0) return `⚠️ EXPIRED (${dateStr}) — run /claude-login`;
153
- if (daysLeft <= 7) return `🚨 expires in ${daysLeft}d (${dateStr}) — run /claude-login NOW`;
154
- if (daysLeft <= 14) return `⚠️ expires in ${daysLeft}d (${dateStr}) — run /claude-login soon`;
155
- return `✅ valid for ${daysLeft} more days (expires ${dateStr})`;
156
- }
148
+ // formatClaudeExpiry imported from ./src/expiry-helpers.js
157
149
  async function scanClaudeCookieExpiry(ctx: BrowserContext): Promise<ClaudeExpiryInfo | null> {
158
150
  try {
159
151
  const cookies = await ctx.cookies(["https://claude.ai"]);
@@ -173,14 +165,7 @@ function saveChatGPTExpiry(info: ChatGPTExpiryInfo): void {
173
165
  function loadChatGPTExpiry(): ChatGPTExpiryInfo | null {
174
166
  try { return JSON.parse(readFileSync(CHATGPT_EXPIRY_FILE, "utf-8")) as ChatGPTExpiryInfo; } catch { return null; }
175
167
  }
176
- function formatChatGPTExpiry(info: ChatGPTExpiryInfo): string {
177
- const daysLeft = Math.floor((info.expiresAt - Date.now()) / 86_400_000);
178
- const dateStr = new Date(info.expiresAt).toISOString().substring(0, 10);
179
- if (daysLeft < 0) return `⚠️ EXPIRED (${dateStr}) — run /chatgpt-login`;
180
- if (daysLeft <= 7) return `🚨 expires in ${daysLeft}d (${dateStr}) — run /chatgpt-login NOW`;
181
- if (daysLeft <= 14) return `⚠️ expires in ${daysLeft}d (${dateStr}) — run /chatgpt-login soon`;
182
- return `✅ valid for ${daysLeft} more days (expires ${dateStr})`;
183
- }
168
+ // formatChatGPTExpiry imported from ./src/expiry-helpers.js
184
169
  async function scanChatGPTCookieExpiry(ctx: BrowserContext): Promise<ChatGPTExpiryInfo | null> {
185
170
  try {
186
171
  const cookies = await ctx.cookies(["https://chatgpt.com", "https://auth0.openai.com"]);
@@ -216,15 +201,7 @@ function loadGrokExpiry(): GrokExpiryInfo | null {
216
201
  } catch { return null; }
217
202
  }
218
203
 
219
- /** Returns human-readable expiry summary e.g. "179 days (2026-09-07)" */
220
- function formatExpiryInfo(info: GrokExpiryInfo): string {
221
- const daysLeft = Math.ceil((info.expiresAt - Date.now()) / 86_400_000);
222
- const dateStr = new Date(info.expiresAt).toISOString().split("T")[0];
223
- if (daysLeft <= 0) return `⚠️ EXPIRED (was ${dateStr})`;
224
- if (daysLeft <= 7) return `🚨 expires in ${daysLeft}d (${dateStr}) — run /grok-login NOW`;
225
- if (daysLeft <= 14) return `⚠️ expires in ${daysLeft}d (${dateStr}) — run /grok-login soon`;
226
- return `✅ valid for ${daysLeft} more days (expires ${dateStr})`;
227
- }
204
+ // formatExpiryInfo imported from ./src/expiry-helpers.js
228
205
 
229
206
  /** Scan context cookies and return earliest auth cookie expiry */
230
207
  async function scanCookieExpiry(ctx: import("playwright").BrowserContext): Promise<GrokExpiryInfo | null> {
@@ -438,21 +415,28 @@ async function getOrLaunchChatGPTContext(
438
415
  return _chatgptLaunchPromise;
439
416
  }
440
417
 
441
- /** Session keep-alive — navigate to provider home pages to refresh cookies */
442
- async function sessionKeepAlive(log: (msg: string) => void): Promise<void> {
418
+ /** Session keep-alive — navigate to provider home pages to refresh cookies.
419
+ * After each touch, verifies the session is still valid. If expired, attempts
420
+ * a full relogin. Returns provider login commands that need manual attention. */
421
+ async function sessionKeepAlive(log: (msg: string) => void): Promise<string[]> {
443
422
  const providers: Array<{
444
423
  name: string;
445
424
  homeUrl: string;
425
+ verifySelector: string;
426
+ loginCmd: string;
446
427
  getCtx: () => BrowserContext | null;
428
+ setCtx: (c: BrowserContext | null) => void;
447
429
  scanExpiry: (ctx: BrowserContext) => Promise<{ expiresAt: number; loginAt: number; cookieName: string } | null>;
448
430
  saveExpiry: (info: { expiresAt: number; loginAt: number; cookieName: string }) => void;
449
431
  }> = [
450
- { name: "grok", homeUrl: "https://grok.com", getCtx: () => grokContext, scanExpiry: scanCookieExpiry, saveExpiry: saveGrokExpiry },
451
- { name: "gemini", homeUrl: "https://gemini.google.com/app", getCtx: () => geminiContext, scanExpiry: scanGeminiCookieExpiry, saveExpiry: saveGeminiExpiry },
452
- { name: "claude-web", homeUrl: "https://claude.ai/new", getCtx: () => claudeWebContext, scanExpiry: scanClaudeCookieExpiry, saveExpiry: saveClaudeExpiry },
453
- { name: "chatgpt", homeUrl: "https://chatgpt.com", getCtx: () => chatgptContext, scanExpiry: scanChatGPTCookieExpiry, saveExpiry: saveChatGPTExpiry },
432
+ { name: "grok", homeUrl: "https://grok.com", verifySelector: "textarea", loginCmd: "/grok-login", getCtx: () => grokContext, setCtx: (c) => { grokContext = c; }, scanExpiry: scanCookieExpiry, saveExpiry: saveGrokExpiry },
433
+ { name: "gemini", homeUrl: "https://gemini.google.com/app", verifySelector: ".ql-editor", loginCmd: "/gemini-login", getCtx: () => geminiContext, setCtx: (c) => { geminiContext = c; }, scanExpiry: scanGeminiCookieExpiry, saveExpiry: saveGeminiExpiry },
434
+ { name: "claude-web", homeUrl: "https://claude.ai/new", verifySelector: ".ProseMirror", loginCmd: "/claude-login", getCtx: () => claudeWebContext, setCtx: (c) => { claudeWebContext = c; }, scanExpiry: scanClaudeCookieExpiry, saveExpiry: saveClaudeExpiry },
435
+ { name: "chatgpt", homeUrl: "https://chatgpt.com", verifySelector: "#prompt-textarea", loginCmd: "/chatgpt-login", getCtx: () => chatgptContext, setCtx: (c) => { chatgptContext = c; }, scanExpiry: scanChatGPTCookieExpiry, saveExpiry: saveChatGPTExpiry },
454
436
  ];
455
437
 
438
+ const needsLogin: string[] = [];
439
+
456
440
  for (const p of providers) {
457
441
  const ctx = p.getCtx();
458
442
  if (!ctx) continue;
@@ -460,16 +444,49 @@ async function sessionKeepAlive(log: (msg: string) => void): Promise<void> {
460
444
  const page = await ctx.newPage();
461
445
  await page.goto(p.homeUrl, { waitUntil: "domcontentloaded", timeout: 15_000 });
462
446
  await new Promise(r => setTimeout(r, 4000));
447
+
448
+ // Verify session is still valid after touch
449
+ let valid = await page.locator(p.verifySelector).isVisible().catch(() => false);
450
+ if (!valid) {
451
+ // Retry once
452
+ await new Promise(r => setTimeout(r, 3000));
453
+ valid = await page.locator(p.verifySelector).isVisible().catch(() => false);
454
+ }
463
455
  await page.close();
464
- const expiry = await p.scanExpiry(ctx);
465
- if (expiry) p.saveExpiry(expiry);
466
- log(`[cli-bridge:${p.name}] session keep-alive touch ✅`);
456
+
457
+ if (valid) {
458
+ const expiry = await p.scanExpiry(ctx);
459
+ if (expiry) p.saveExpiry(expiry);
460
+ log(`[cli-bridge:${p.name}] session keep-alive touch ✅`);
461
+ } else {
462
+ // Session expired — attempt relogin in same persistent context
463
+ log(`[cli-bridge:${p.name}] session expired after keep-alive — attempting auto-relogin…`);
464
+ const reloginPage = await ctx.newPage();
465
+ await reloginPage.goto(p.homeUrl, { waitUntil: "domcontentloaded", timeout: 20_000 });
466
+ await new Promise(r => setTimeout(r, 6000));
467
+ let reloginOk = await reloginPage.locator(p.verifySelector).isVisible().catch(() => false);
468
+ if (!reloginOk) {
469
+ await new Promise(r => setTimeout(r, 3000));
470
+ reloginOk = await reloginPage.locator(p.verifySelector).isVisible().catch(() => false);
471
+ }
472
+ await reloginPage.close().catch(() => {});
473
+ if (reloginOk) {
474
+ const expiry = await p.scanExpiry(ctx);
475
+ if (expiry) p.saveExpiry(expiry);
476
+ log(`[cli-bridge:${p.name}] auto-relogin successful ✅`);
477
+ } else {
478
+ log(`[cli-bridge:${p.name}] auto-relogin failed, needs manual ${p.loginCmd}`);
479
+ needsLogin.push(p.loginCmd);
480
+ }
481
+ }
467
482
  } catch (err) {
468
483
  log(`[cli-bridge:${p.name}] session keep-alive failed: ${(err as Error).message}`);
469
484
  }
470
485
  // Sequential — avoid spawning multiple pages at once
471
486
  await new Promise(r => setTimeout(r, 2000));
472
487
  }
488
+
489
+ return needsLogin;
473
490
  }
474
491
 
475
492
  /** Clean up all browser resources — call on plugin teardown */
@@ -895,7 +912,7 @@ function proxyTestRequest(
895
912
  const plugin = {
896
913
  id: "openclaw-cli-bridge-elvatis",
897
914
  name: "OpenClaw CLI Bridge",
898
- version: "1.6.5",
915
+ version: "1.7.0",
899
916
  description:
900
917
  "Phase 1: openai-codex auth bridge. " +
901
918
  "Phase 2: HTTP proxy for gemini/claude CLIs. " +
@@ -996,17 +1013,47 @@ const plugin = {
996
1013
  });
997
1014
  const page = await ctx.newPage();
998
1015
  await page.goto(p.homeUrl, { waitUntil: "domcontentloaded", timeout: 20_000 });
999
- await new Promise(r => setTimeout(r, 3000));
1000
- const ok = await page.locator(p.verifySelector).isVisible().catch(() => false);
1016
+ await new Promise(r => setTimeout(r, 6000));
1017
+ let ok = await page.locator(p.verifySelector).isVisible().catch(() => false);
1018
+ // Retry once — some pages (Grok) load slowly
1019
+ if (!ok) {
1020
+ api.logger.info(`[cli-bridge:${p.name}] verifySelector not visible after 6s, retrying (3s)…`);
1021
+ await new Promise(r => setTimeout(r, 3000));
1022
+ ok = await page.locator(p.verifySelector).isVisible().catch(() => false);
1023
+ }
1001
1024
  await page.close().catch(() => {});
1002
1025
  if (ok) {
1003
1026
  p.setCtx(ctx);
1004
1027
  ctx.on("close", () => { p.setCtx(null as unknown as BrowserContext); });
1005
1028
  api.logger.info(`[cli-bridge:${p.name}] session restored from profile ✅`);
1006
1029
  } else {
1007
- await ctx.close().catch(() => {});
1008
- api.logger.info(`[cli-bridge:${p.name}] profile exists but not logged in needs ${p.loginCmd}`);
1009
- needsLogin.push(p.loginCmd);
1030
+ // Session may be truly expired or just slow — attempt auto-relogin:
1031
+ // re-navigate in the same context and check once more
1032
+ api.logger.info(`[cli-bridge:${p.name}] not logged in after restore — attempting auto-relogin…`);
1033
+ try {
1034
+ const reloginPage = await ctx.newPage();
1035
+ await reloginPage.goto(p.homeUrl, { waitUntil: "domcontentloaded", timeout: 20_000 });
1036
+ await new Promise(r => setTimeout(r, 6000));
1037
+ let reloginOk = await reloginPage.locator(p.verifySelector).isVisible().catch(() => false);
1038
+ if (!reloginOk) {
1039
+ await new Promise(r => setTimeout(r, 3000));
1040
+ reloginOk = await reloginPage.locator(p.verifySelector).isVisible().catch(() => false);
1041
+ }
1042
+ await reloginPage.close().catch(() => {});
1043
+ if (reloginOk) {
1044
+ p.setCtx(ctx);
1045
+ ctx.on("close", () => { p.setCtx(null as unknown as BrowserContext); });
1046
+ api.logger.info(`[cli-bridge:${p.name}] session restored (slow load) ✅`);
1047
+ } else {
1048
+ await ctx.close().catch(() => {});
1049
+ api.logger.info(`[cli-bridge:${p.name}] auto-relogin failed, needs manual ${p.loginCmd}`);
1050
+ needsLogin.push(p.loginCmd);
1051
+ }
1052
+ } catch (reloginErr) {
1053
+ await ctx.close().catch(() => {});
1054
+ api.logger.warn(`[cli-bridge:${p.name}] auto-relogin error: ${(reloginErr as Error).message}`);
1055
+ needsLogin.push(p.loginCmd);
1056
+ }
1010
1057
  }
1011
1058
  } catch (err) {
1012
1059
  api.logger.warn(`[cli-bridge:${p.name}] startup restore failed: ${(err as Error).message}`);
@@ -1036,7 +1083,22 @@ const plugin = {
1036
1083
  // Start session keep-alive interval (every 20h)
1037
1084
  if (!_keepAliveInterval) {
1038
1085
  _keepAliveInterval = setInterval(() => {
1039
- void sessionKeepAlive((msg) => api.logger.info(msg));
1086
+ void (async () => {
1087
+ const failed = await sessionKeepAlive((msg) => api.logger.info(msg));
1088
+ if (failed.length > 0) {
1089
+ const cmds = failed.map(cmd => `• ${cmd}`).join("\n");
1090
+ const msg = `🔐 *cli-bridge keep-alive:* Session expired for ${failed.length} provider(s). Run to re-login:\n\n${cmds}`;
1091
+ try {
1092
+ await api.runtime.system.runCommandWithTimeout(
1093
+ ["openclaw", "message", "send", "--channel", "whatsapp", "--to", "+4915170113694", "--message", msg],
1094
+ { timeoutMs: 10_000 }
1095
+ );
1096
+ api.logger.info(`[cli-bridge] keep-alive: sent re-login notification for: ${failed.join(", ")}`);
1097
+ } catch (err) {
1098
+ api.logger.warn(`[cli-bridge] keep-alive: failed to send notification: ${(err as Error).message}`);
1099
+ }
1100
+ }
1101
+ })();
1040
1102
  }, 72_000_000);
1041
1103
  }
1042
1104
  }
@@ -1178,6 +1240,13 @@ const plugin = {
1178
1240
  }
1179
1241
  return chatgptContext;
1180
1242
  },
1243
+ version: plugin.version,
1244
+ getExpiryInfo: () => ({
1245
+ grok: (() => { const e = loadGrokExpiry(); return e ? formatExpiryInfo(e) : null; })(),
1246
+ gemini: (() => { const e = loadGeminiExpiry(); return e ? formatGeminiExpiry(e) : null; })(),
1247
+ claude: (() => { const e = loadClaudeExpiry(); return e ? formatClaudeExpiry(e) : null; })(),
1248
+ chatgpt: (() => { const e = loadChatGPTExpiry(); return e ? formatChatGPTExpiry(e) : null; })(),
1249
+ }),
1181
1250
  });
1182
1251
  proxyServer = server;
1183
1252
  api.logger.info(
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-cli-bridge-elvatis",
3
3
  "name": "OpenClaw CLI Bridge",
4
- "version": "1.6.5",
4
+ "version": "1.7.1",
5
5
  "license": "MIT",
6
6
  "description": "Phase 1: openai-codex auth bridge. Phase 2: local HTTP proxy routing model calls through gemini/claude CLIs (vllm provider).",
7
7
  "providers": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elvatis_com/openclaw-cli-bridge-elvatis",
3
- "version": "1.6.5",
3
+ "version": "1.7.1",
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": {
@@ -0,0 +1,52 @@
1
+ /**
2
+ * expiry-helpers.ts
3
+ *
4
+ * Pure functions for formatting cookie expiry info.
5
+ * Extracted from index.ts for testability.
6
+ */
7
+
8
+ export interface ExpiryInfo {
9
+ expiresAt: number; // epoch ms
10
+ loginAt: number; // epoch ms
11
+ cookieName: string;
12
+ }
13
+
14
+ /** Grok cookie expiry — uses Math.ceil for daysLeft */
15
+ export function formatExpiryInfo(info: ExpiryInfo): string {
16
+ const daysLeft = Math.ceil((info.expiresAt - Date.now()) / 86_400_000);
17
+ const dateStr = new Date(info.expiresAt).toISOString().split("T")[0];
18
+ if (daysLeft <= 0) return `⚠️ EXPIRED (was ${dateStr})`;
19
+ if (daysLeft <= 7) return `🚨 expires in ${daysLeft}d (${dateStr}) — run /grok-login NOW`;
20
+ if (daysLeft <= 14) return `⚠️ expires in ${daysLeft}d (${dateStr}) — run /grok-login soon`;
21
+ return `✅ valid for ${daysLeft} more days (expires ${dateStr})`;
22
+ }
23
+
24
+ /** Gemini cookie expiry — uses Math.floor for daysLeft */
25
+ export function formatGeminiExpiry(info: ExpiryInfo): string {
26
+ const daysLeft = Math.floor((info.expiresAt - Date.now()) / 86_400_000);
27
+ const dateStr = new Date(info.expiresAt).toISOString().substring(0, 10);
28
+ if (daysLeft < 0) return `⚠️ EXPIRED (${dateStr}) — run /gemini-login`;
29
+ if (daysLeft <= 7) return `🚨 expires in ${daysLeft}d (${dateStr}) — run /gemini-login NOW`;
30
+ if (daysLeft <= 14) return `⚠️ expires in ${daysLeft}d (${dateStr}) — run /gemini-login soon`;
31
+ return `✅ valid for ${daysLeft} more days (expires ${dateStr})`;
32
+ }
33
+
34
+ /** Claude cookie expiry — uses Math.floor for daysLeft */
35
+ export function formatClaudeExpiry(info: ExpiryInfo): string {
36
+ const daysLeft = Math.floor((info.expiresAt - Date.now()) / 86_400_000);
37
+ const dateStr = new Date(info.expiresAt).toISOString().substring(0, 10);
38
+ if (daysLeft < 0) return `⚠️ EXPIRED (${dateStr}) — run /claude-login`;
39
+ if (daysLeft <= 7) return `🚨 expires in ${daysLeft}d (${dateStr}) — run /claude-login NOW`;
40
+ if (daysLeft <= 14) return `⚠️ expires in ${daysLeft}d (${dateStr}) — run /claude-login soon`;
41
+ return `✅ valid for ${daysLeft} more days (expires ${dateStr})`;
42
+ }
43
+
44
+ /** ChatGPT cookie expiry — uses Math.floor for daysLeft */
45
+ export function formatChatGPTExpiry(info: ExpiryInfo): string {
46
+ const daysLeft = Math.floor((info.expiresAt - Date.now()) / 86_400_000);
47
+ const dateStr = new Date(info.expiresAt).toISOString().substring(0, 10);
48
+ if (daysLeft < 0) return `⚠️ EXPIRED (${dateStr}) — run /chatgpt-login`;
49
+ if (daysLeft <= 7) return `🚨 expires in ${daysLeft}d (${dateStr}) — run /chatgpt-login NOW`;
50
+ if (daysLeft <= 14) return `⚠️ expires in ${daysLeft}d (${dateStr}) — run /chatgpt-login soon`;
51
+ return `✅ valid for ${daysLeft} more days (expires ${dateStr})`;
52
+ }
@@ -60,6 +60,15 @@ export interface ProxyServerOptions {
60
60
  _chatgptComplete?: typeof chatgptComplete;
61
61
  /** Override for testing — replaces chatgptCompleteStream */
62
62
  _chatgptCompleteStream?: typeof chatgptCompleteStream;
63
+ /** Returns human-readable expiry string for each web provider (null = no login yet) */
64
+ getExpiryInfo?: () => {
65
+ grok: string | null;
66
+ gemini: string | null;
67
+ claude: string | null;
68
+ chatgpt: string | null;
69
+ };
70
+ /** Plugin version string for the status page */
71
+ version?: string;
63
72
  }
64
73
 
65
74
  /** Available CLI bridge models for GET /v1/models */
@@ -156,6 +165,111 @@ async function handleRequest(
156
165
  return;
157
166
  }
158
167
 
168
+ // Browser status page — human-readable HTML dashboard
169
+ if ((url === "/status" || url === "/") && req.method === "GET") {
170
+ const expiry = opts.getExpiryInfo?.() ?? { grok: null, gemini: null, claude: null, chatgpt: null };
171
+ const version = opts.version ?? "?";
172
+
173
+ const providers = [
174
+ { name: "Grok", icon: "𝕏", expiry: expiry.grok, loginCmd: "/grok-login", ctx: opts.getGrokContext?.() ?? null },
175
+ { name: "Gemini", icon: "✦", expiry: expiry.gemini, loginCmd: "/gemini-login", ctx: opts.getGeminiContext?.() ?? null },
176
+ { name: "Claude", icon: "◆", expiry: expiry.claude, loginCmd: "/claude-login", ctx: opts.getClaudeContext?.() ?? null },
177
+ { name: "ChatGPT", icon: "◉", expiry: expiry.chatgpt, loginCmd: "/chatgpt-login", ctx: opts.getChatGPTContext?.() ?? null },
178
+ ];
179
+
180
+ function statusBadge(p: typeof providers[0]): { label: string; color: string; dot: string } {
181
+ if (p.ctx !== null) return { label: "Connected", color: "#22c55e", dot: "🟢" };
182
+ if (!p.expiry) return { label: "Never logged in", color: "#6b7280", dot: "⚪" };
183
+ if (p.expiry.startsWith("⚠️ EXPIRED")) return { label: "Expired", color: "#ef4444", dot: "🔴" };
184
+ if (p.expiry.startsWith("🚨")) return { label: "Expiring soon", color: "#f59e0b", dot: "🟡" };
185
+ return { label: "Logged in", color: "#3b82f6", dot: "🔵" };
186
+ }
187
+
188
+ const rows = providers.map(p => {
189
+ const badge = statusBadge(p);
190
+ const expiryText = p.expiry
191
+ ? p.expiry.replace(/[⚠️🚨✅🕐]/gu, "").trim()
192
+ : `Not logged in — run <code>${p.loginCmd}</code>`;
193
+ return `
194
+ <tr>
195
+ <td style="padding:12px 16px;font-weight:600;font-size:15px">${p.icon} ${p.name}</td>
196
+ <td style="padding:12px 16px">
197
+ <span style="background:${badge.color}22;color:${badge.color};border:1px solid ${badge.color}44;
198
+ border-radius:6px;padding:3px 10px;font-size:13px;font-weight:600">
199
+ ${badge.dot} ${badge.label}
200
+ </span>
201
+ </td>
202
+ <td style="padding:12px 16px;color:#9ca3af;font-size:13px">${expiryText}</td>
203
+ <td style="padding:12px 16px;color:#6b7280;font-size:12px;font-family:monospace">${p.loginCmd}</td>
204
+ </tr>`;
205
+ }).join("");
206
+
207
+ const cliModels = CLI_MODELS.filter(m => m.id.startsWith("cli-"));
208
+ const webModels = CLI_MODELS.filter(m => m.id.startsWith("web-"));
209
+ const modelList = (models: typeof CLI_MODELS) =>
210
+ models.map(m => `<li style="margin:2px 0;font-size:13px;color:#d1d5db"><code style="color:#93c5fd">${m.id}</code></li>`).join("");
211
+
212
+ const html = `<!DOCTYPE html>
213
+ <html lang="en">
214
+ <head>
215
+ <meta charset="UTF-8">
216
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
217
+ <title>CLI Bridge Status</title>
218
+ <meta http-equiv="refresh" content="30">
219
+ <style>
220
+ * { box-sizing: border-box; margin: 0; padding: 0; }
221
+ body { background: #0f1117; color: #e5e7eb; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; min-height: 100vh; padding: 32px 24px; }
222
+ h1 { font-size: 22px; font-weight: 700; color: #f9fafb; margin-bottom: 4px; }
223
+ .subtitle { color: #6b7280; font-size: 13px; margin-bottom: 28px; }
224
+ .card { background: #1a1d27; border: 1px solid #2d3148; border-radius: 12px; overflow: hidden; margin-bottom: 24px; }
225
+ .card-header { padding: 14px 16px; border-bottom: 1px solid #2d3148; font-size: 12px; font-weight: 600; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em; }
226
+ table { width: 100%; border-collapse: collapse; }
227
+ tr:not(:last-child) td { border-bottom: 1px solid #1f2335; }
228
+ .models { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
229
+ ul { list-style: none; padding: 12px 16px; }
230
+ .footer { color: #374151; font-size: 12px; text-align: center; margin-top: 16px; }
231
+ code { background: #1e2130; padding: 1px 5px; border-radius: 4px; }
232
+ </style>
233
+ </head>
234
+ <body>
235
+ <h1>🌉 CLI Bridge</h1>
236
+ <p class="subtitle">v${version} &nbsp;·&nbsp; Port ${opts.port} &nbsp;·&nbsp; Auto-refreshes every 30s</p>
237
+
238
+ <div class="card">
239
+ <div class="card-header">Web Session Providers</div>
240
+ <table>
241
+ <thead>
242
+ <tr style="background:#13151f">
243
+ <th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Provider</th>
244
+ <th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Status</th>
245
+ <th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Session</th>
246
+ <th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Login</th>
247
+ </tr>
248
+ </thead>
249
+ <tbody>${rows}</tbody>
250
+ </table>
251
+ </div>
252
+
253
+ <div class="models">
254
+ <div class="card">
255
+ <div class="card-header">CLI Models (${cliModels.length})</div>
256
+ <ul>${modelList(cliModels)}</ul>
257
+ </div>
258
+ <div class="card">
259
+ <div class="card-header">Web Session Models (${webModels.length})</div>
260
+ <ul>${modelList(webModels)}</ul>
261
+ </div>
262
+ </div>
263
+
264
+ <p class="footer">openclaw-cli-bridge-elvatis v${version} &nbsp;·&nbsp; <a href="/v1/models" style="color:#4b5563">/v1/models</a> &nbsp;·&nbsp; <a href="/health" style="color:#4b5563">/health</a></p>
265
+ </body>
266
+ </html>`;
267
+
268
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
269
+ res.end(html);
270
+ return;
271
+ }
272
+
159
273
  // Model list
160
274
  if (url === "/v1/models" && req.method === "GET") {
161
275
  const now = Math.floor(Date.now() / 1000);
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Tests for cookie expiry formatting helpers.
3
+ */
4
+
5
+ import { describe, it, expect, vi, afterEach } from "vitest";
6
+ import {
7
+ formatExpiryInfo,
8
+ formatGeminiExpiry,
9
+ formatClaudeExpiry,
10
+ formatChatGPTExpiry,
11
+ type ExpiryInfo,
12
+ } from "../src/expiry-helpers.js";
13
+
14
+ // Fix Date.now() for deterministic tests
15
+ const NOW = new Date("2026-03-13T12:00:00Z").getTime();
16
+
17
+ afterEach(() => {
18
+ vi.restoreAllMocks();
19
+ });
20
+
21
+ function makeExpiry(daysFromNow: number): ExpiryInfo {
22
+ return {
23
+ expiresAt: NOW + daysFromNow * 86_400_000,
24
+ loginAt: NOW - 86_400_000,
25
+ cookieName: "test-cookie",
26
+ };
27
+ }
28
+
29
+ // ──────────────────────────────────────────────────────────────────────────────
30
+ // formatExpiryInfo (Grok)
31
+ // ──────────────────────────────────────────────────────────────────────────────
32
+
33
+ describe("formatExpiryInfo (Grok)", () => {
34
+ it("expired → contains EXPIRED", () => {
35
+ vi.spyOn(Date, "now").mockReturnValue(NOW);
36
+ const result = formatExpiryInfo(makeExpiry(-5));
37
+ expect(result).toContain("EXPIRED");
38
+ });
39
+
40
+ it("3 days → contains 🚨", () => {
41
+ vi.spyOn(Date, "now").mockReturnValue(NOW);
42
+ const result = formatExpiryInfo(makeExpiry(3));
43
+ expect(result).toContain("🚨");
44
+ expect(result).toContain("3d");
45
+ });
46
+
47
+ it("20 days → contains ✅", () => {
48
+ vi.spyOn(Date, "now").mockReturnValue(NOW);
49
+ const result = formatExpiryInfo(makeExpiry(20));
50
+ expect(result).toContain("✅");
51
+ expect(result).toContain("20");
52
+ });
53
+
54
+ it("10 days → contains ⚠️ (warning zone 7-14d)", () => {
55
+ vi.spyOn(Date, "now").mockReturnValue(NOW);
56
+ const result = formatExpiryInfo(makeExpiry(10));
57
+ expect(result).toContain("⚠️");
58
+ });
59
+ });
60
+
61
+ // ──────────────────────────────────────────────────────────────────────────────
62
+ // formatGeminiExpiry
63
+ // ──────────────────────────────────────────────────────────────────────────────
64
+
65
+ describe("formatGeminiExpiry", () => {
66
+ it("expired → contains EXPIRED", () => {
67
+ vi.spyOn(Date, "now").mockReturnValue(NOW);
68
+ const result = formatGeminiExpiry(makeExpiry(-2));
69
+ expect(result).toContain("EXPIRED");
70
+ expect(result).toContain("/gemini-login");
71
+ });
72
+
73
+ it("3 days → contains 🚨", () => {
74
+ vi.spyOn(Date, "now").mockReturnValue(NOW);
75
+ const result = formatGeminiExpiry(makeExpiry(3));
76
+ expect(result).toContain("🚨");
77
+ });
78
+
79
+ it("20 days → contains ✅", () => {
80
+ vi.spyOn(Date, "now").mockReturnValue(NOW);
81
+ const result = formatGeminiExpiry(makeExpiry(20));
82
+ expect(result).toContain("✅");
83
+ });
84
+ });
85
+
86
+ // ──────────────────────────────────────────────────────────────────────────────
87
+ // formatClaudeExpiry
88
+ // ──────────────────────────────────────────────────────────────────────────────
89
+
90
+ describe("formatClaudeExpiry", () => {
91
+ it("expired → contains EXPIRED", () => {
92
+ vi.spyOn(Date, "now").mockReturnValue(NOW);
93
+ const result = formatClaudeExpiry(makeExpiry(-1));
94
+ expect(result).toContain("EXPIRED");
95
+ expect(result).toContain("/claude-login");
96
+ });
97
+
98
+ it("3 days → contains 🚨", () => {
99
+ vi.spyOn(Date, "now").mockReturnValue(NOW);
100
+ const result = formatClaudeExpiry(makeExpiry(3));
101
+ expect(result).toContain("🚨");
102
+ });
103
+
104
+ it("20 days → contains ✅", () => {
105
+ vi.spyOn(Date, "now").mockReturnValue(NOW);
106
+ const result = formatClaudeExpiry(makeExpiry(20));
107
+ expect(result).toContain("✅");
108
+ });
109
+ });
110
+
111
+ // ──────────────────────────────────────────────────────────────────────────────
112
+ // formatChatGPTExpiry
113
+ // ──────────────────────────────────────────────────────────────────────────────
114
+
115
+ describe("formatChatGPTExpiry", () => {
116
+ it("expired → contains EXPIRED", () => {
117
+ vi.spyOn(Date, "now").mockReturnValue(NOW);
118
+ const result = formatChatGPTExpiry(makeExpiry(-3));
119
+ expect(result).toContain("EXPIRED");
120
+ expect(result).toContain("/chatgpt-login");
121
+ });
122
+
123
+ it("3 days → contains 🚨", () => {
124
+ vi.spyOn(Date, "now").mockReturnValue(NOW);
125
+ const result = formatChatGPTExpiry(makeExpiry(3));
126
+ expect(result).toContain("🚨");
127
+ });
128
+
129
+ it("20 days → contains ✅", () => {
130
+ vi.spyOn(Date, "now").mockReturnValue(NOW);
131
+ const result = formatChatGPTExpiry(makeExpiry(20));
132
+ expect(result).toContain("✅");
133
+ });
134
+ });
@@ -395,3 +395,83 @@ describe("Error handling", () => {
395
395
  expect(JSON.parse(res.body).error.type).toBe("not_found");
396
396
  });
397
397
  });
398
+
399
+ // ──────────────────────────────────────────────────────────────────────────────
400
+ // Tool/function call rejection for CLI-proxy models
401
+ // ──────────────────────────────────────────────────────────────────────────────
402
+
403
+ describe("Tool call rejection", () => {
404
+ it("rejects tools for cli-gemini models with tools_not_supported", async () => {
405
+ const res = await json("/v1/chat/completions", {
406
+ model: "cli-gemini/gemini-2.5-pro",
407
+ messages: [{ role: "user", content: "hi" }],
408
+ tools: [{ type: "function", function: { name: "test", parameters: {} } }],
409
+ });
410
+
411
+ expect(res.status).toBe(400);
412
+ const body = JSON.parse(res.body);
413
+ expect(body.error.code).toBe("tools_not_supported");
414
+ });
415
+
416
+ it("rejects tools for cli-claude models with tools_not_supported", async () => {
417
+ const res = await json("/v1/chat/completions", {
418
+ model: "cli-claude/claude-sonnet-4-6",
419
+ messages: [{ role: "user", content: "hi" }],
420
+ tools: [{ type: "function", function: { name: "test", parameters: {} } }],
421
+ });
422
+
423
+ expect(res.status).toBe(400);
424
+ const body = JSON.parse(res.body);
425
+ expect(body.error.code).toBe("tools_not_supported");
426
+ });
427
+
428
+ it("does NOT reject tools for web-grok models (returns 503 no session)", async () => {
429
+ const res = await json("/v1/chat/completions", {
430
+ model: "web-grok/grok-3",
431
+ messages: [{ role: "user", content: "hi" }],
432
+ tools: [{ type: "function", function: { name: "test", parameters: {} } }],
433
+ });
434
+
435
+ // Should NOT be 400 tools_not_supported — reaches provider logic, gets 503 (no session)
436
+ expect(res.status).not.toBe(400);
437
+ expect(res.status).toBe(503);
438
+ const body = JSON.parse(res.body);
439
+ expect(body.error.code).toBe("no_grok_session");
440
+ });
441
+ });
442
+
443
+ // ──────────────────────────────────────────────────────────────────────────────
444
+ // Model capabilities
445
+ // ──────────────────────────────────────────────────────────────────────────────
446
+
447
+ describe("Model capabilities", () => {
448
+ it("cli-gemini models have capabilities.tools===false", async () => {
449
+ const res = await fetch("/v1/models");
450
+ const body = JSON.parse(res.body);
451
+ const cliGeminiModels = body.data.filter((m: { id: string }) => m.id.startsWith("cli-gemini/"));
452
+ expect(cliGeminiModels.length).toBeGreaterThan(0);
453
+ for (const m of cliGeminiModels) {
454
+ expect(m.capabilities.tools).toBe(false);
455
+ }
456
+ });
457
+
458
+ it("cli-claude models have capabilities.tools===false", async () => {
459
+ const res = await fetch("/v1/models");
460
+ const body = JSON.parse(res.body);
461
+ const cliClaudeModels = body.data.filter((m: { id: string }) => m.id.startsWith("cli-claude/"));
462
+ expect(cliClaudeModels.length).toBeGreaterThan(0);
463
+ for (const m of cliClaudeModels) {
464
+ expect(m.capabilities.tools).toBe(false);
465
+ }
466
+ });
467
+
468
+ it("web-grok models have capabilities.tools===true", async () => {
469
+ const res = await fetch("/v1/models");
470
+ const body = JSON.parse(res.body);
471
+ const webGrokModels = body.data.filter((m: { id: string }) => m.id.startsWith("web-grok/"));
472
+ expect(webGrokModels.length).toBeGreaterThan(0);
473
+ for (const m of webGrokModels) {
474
+ expect(m.capabilities.tools).toBe(true);
475
+ }
476
+ });
477
+ });