@elvatis_com/openclaw-cli-bridge-elvatis 1.5.0 → 1.6.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.
@@ -0,0 +1,93 @@
1
+ # Contributing & Release Checklist
2
+
3
+ ## 🚨 Pflicht-Workflow vor jedem Release
4
+
5
+ **Kein Publish ohne erfolgreichen `/bridge-status` Test!**
6
+
7
+ ### 1. Build
8
+
9
+ ```bash
10
+ npm run build
11
+ # Exit 0 erwartet — TS-Fehler sind ok (--noEmitOnError false), aber Exit != 0 blockiert
12
+ ```
13
+
14
+ ### 2. Gateway neu starten
15
+
16
+ ```bash
17
+ openclaw gateway restart
18
+ # oder via Chat: gateway restart
19
+ ```
20
+
21
+ ### 3. Smoke Tests (alle müssen grün sein)
22
+
23
+ ```
24
+ /bridge-status → Grok ✅ + Gemini ✅ (beide connected, nicht "not connected")
25
+ /cli-test → CLI bridge OK, Latency < 10s
26
+ /grok-status → valid (Cookie-Expiry prüfen)
27
+ /gemini-status → valid (Cookie-Expiry prüfen)
28
+ ```
29
+
30
+ **Erst wenn alle Tests grün sind → publishen!**
31
+
32
+ ### 4. Publish (Reihenfolge einhalten)
33
+
34
+ ```bash
35
+ # 1. Version bump in package.json + openclaw.plugin.json (beide!)
36
+ # 2. Git commit + tag
37
+ git add package.json openclaw.plugin.json
38
+ git commit -m "chore: bump to vX.Y.Z — <kurze Beschreibung>"
39
+ git tag vX.Y.Z
40
+ git push origin main vX.Y.Z
41
+
42
+ # 3. GitHub Release erstellen (Tag ≠ Release!)
43
+ gh release create vX.Y.Z --title "vX.Y.Z — <Titel>" --notes "<Notes>" --latest
44
+
45
+ # 4. npm publish
46
+ npm publish --access public
47
+
48
+ # 5. ClawHub publish (aus tmp-Dir, nicht direkt aus Repo)
49
+ TMPDIR=$(mktemp -d)
50
+ rsync -a --exclude='node_modules' --exclude='.git' --exclude='dist' \
51
+ --exclude='package-lock.json' --exclude='test' ./ "$TMPDIR/"
52
+ clawhub publish "$TMPDIR" --slug openclaw-cli-bridge-elvatis --version X.Y.Z \
53
+ --tags "latest" --changelog "<Changelog>"
54
+ ```
55
+
56
+ > ⚠️ **ClawHub Bug (CLI v0.7.0):** `acceptLicenseTerms: invalid value` — Workaround: `publish.js` vor dem Publish patchen und danach zurücksetzen. Details in AGENTS.md / MEMORY.md des Workspaces.
57
+
58
+ ---
59
+
60
+ ## Versionsstellen — alle prüfen vor Release
61
+
62
+ ```bash
63
+ grep -rn "X\.Y\.Z\|version" \
64
+ --include="*.md" --include="*.json" \
65
+ --exclude-dir=node_modules --exclude-dir=dist --exclude-dir=.git \
66
+ | grep -i "version"
67
+ ```
68
+
69
+ Typische Stellen:
70
+ - `package.json` → `"version": "..."`
71
+ - `openclaw.plugin.json` → `"version": "..."`
72
+ - `README.md` → `**Current version:** ...` (falls vorhanden)
73
+
74
+ ---
75
+
76
+ ## Breaking Changes
77
+
78
+ Bei Breaking Changes (Major oder entfernte Commands):
79
+ - Version-Bump auf nächste **Minor** (z.B. 1.4.x → 1.5.0)
80
+ - GitHub Release Notes: `## ⚠️ Breaking Change` Sektion
81
+ - README Changelog: Was wurde entfernt + warum
82
+ - `/bridge-status` muss die entfernten Provider NICHT mehr zeigen
83
+
84
+ ---
85
+
86
+ ## TS-Build-Fehler
87
+
88
+ Die folgenden TS-Fehler sind bekannt und ignorierbar (kein Runtime-Problem):
89
+ - `TS2307: Cannot find module 'openclaw/plugin-sdk'` — Typ-Deklarationen fehlen in npm-Paket
90
+ - `TS2339: Property 'handler' does not exist on type 'unknown'` — folgt aus TS2307
91
+ - `TS7006: Parameter implicitly has 'any' type` — minor, kein Effekt
92
+
93
+ Build läuft mit `--noEmitOnError false` durch. `npm run build` → Exit 0 ist das Kriterium, nicht null TS-Fehler.
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.5.0`
5
+ **Current version:** `1.6.1`
6
6
 
7
7
  ---
8
8
 
@@ -115,11 +115,42 @@ Routes requests through real browser sessions on the provider's web UI. Requires
115
115
  | `/gemini-status` | Show session validity + cookie expiry |
116
116
  | `/gemini-logout` | Clear session |
117
117
 
118
+ **Claude.ai** (claude.ai — Pro/Team subscription):
119
+
120
+ | Model | Notes |
121
+ |---|---|
122
+ | `web-claude/claude-sonnet` | Claude Sonnet (web) |
123
+ | `web-claude/claude-opus` | Claude Opus (web) |
124
+ | `web-claude/claude-haiku` | Claude Haiku (web) |
125
+
126
+ | Command | What it does |
127
+ |---|---|
128
+ | `/claude-login` | Authenticate, save cookies to `~/.openclaw/claude-profile/` |
129
+ | `/claude-status` | Show session validity + cookie expiry |
130
+ | `/claude-logout` | Clear session |
131
+
132
+ **ChatGPT** (chatgpt.com — Plus/Pro subscription):
133
+
134
+ | Model | Notes |
135
+ |---|---|
136
+ | `web-chatgpt/gpt-4o` | GPT-4o |
137
+ | `web-chatgpt/gpt-4o-mini` | GPT-4o Mini |
138
+ | `web-chatgpt/gpt-o3` | GPT o3 |
139
+ | `web-chatgpt/gpt-o4-mini` | GPT o4-mini |
140
+ | `web-chatgpt/gpt-5` | GPT-5 |
141
+
142
+ | Command | What it does |
143
+ |---|---|
144
+ | `/chatgpt-login` | Authenticate, save cookies to `~/.openclaw/chatgpt-profile/` |
145
+ | `/chatgpt-status` | Show session validity + cookie expiry |
146
+ | `/chatgpt-logout` | Clear session |
147
+
118
148
  **Session lifecycle:**
119
- - First use: run `/xxx-login` once - authenticates and saves cookies to persistent Chromium profile
149
+ - First use: run `/xxx-login` once authenticates and saves cookies to persistent Chromium profile
120
150
  - **No CDP required:** `/xxx-login` no longer depends on the OpenClaw browser (CDP port 18800). If CDP is available, cookies are imported from it; otherwise a standalone persistent Chromium is launched automatically.
151
+ - If headless login fails, a **headed browser** opens for manual login (5 min timeout)
121
152
  - After gateway restart: sessions are **automatically restored** from saved profiles on startup (sequential, ~25s after start)
122
- - `/bridge-status` — shows all providers at a glance with login state + expiry info
153
+ - `/bridge-status` — shows all 4 providers at a glance with login state + expiry info
123
154
 
124
155
  ---
125
156
 
package/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: openclaw-cli-bridge-elvatis
3
- description: Bridge local Codex, Gemini, and Claude Code CLIs into OpenClaw as vllm model providers. Includes /cli-* slash commands for instant model switching (/cli-sonnet, /cli-opus, /cli-haiku, /cli-gemini, /cli-gemini-flash, /cli-gemini3). E2BIG-safe spawn via minimal env.
3
+ description: Bridge local AI CLIs + web browser sessions (Grok, Gemini, Claude.ai, ChatGPT) into OpenClaw as model providers. Includes /cli-* slash commands for instant model switching and persistent browser profiles for all 4 web providers.
4
4
  homepage: https://github.com/elvatis/openclaw-cli-bridge-elvatis
5
5
  metadata:
6
6
  {
@@ -15,7 +15,7 @@ metadata:
15
15
 
16
16
  # OpenClaw CLI Bridge
17
17
 
18
- Bridges locally installed AI CLIs (Codex, Gemini, Claude Code) as OpenClaw model providers. Three phases:
18
+ Bridges locally installed AI CLIs + web browser sessions into OpenClaw as model providers. Four phases:
19
19
 
20
20
  ## Phase 1 — Codex Auth Bridge
21
21
  Registers `openai-codex` provider from existing `~/.codex/auth.json` tokens. No re-login.
@@ -43,7 +43,16 @@ Six instant model-switch commands (authorized senders only):
43
43
  | `/cli-back` | Restore previous model |
44
44
  | `/cli-test [model]` | Health check (no model switch) |
45
45
 
46
- Each command runs `openclaw models set <model>` atomically and replies with a confirmation.
46
+ Each command uses staged switching by default (apply with `/cli-apply`).
47
+
48
+ ## Phase 4 — Web Browser Providers
49
+ Persistent Chromium profiles for 4 web providers (no API key needed):
50
+ - **Grok** (`web-grok/*`): `/grok-login`, `/grok-status`, `/grok-logout`
51
+ - **Gemini** (`web-gemini/*`): `/gemini-login`, `/gemini-status`, `/gemini-logout`
52
+ - **Claude.ai** (`web-claude/*`): `/claude-login`, `/claude-status`, `/claude-logout`
53
+ - **ChatGPT** (`web-chatgpt/*`): `/chatgpt-login`, `/chatgpt-status`, `/chatgpt-logout`
54
+
55
+ Sessions survive gateway restarts. `/bridge-status` shows all 4 at a glance.
47
56
 
48
57
  ## Setup
49
58
 
@@ -53,4 +62,4 @@ Each command runs `openclaw models set <model>` atomically and replies with a co
53
62
 
54
63
  See `README.md` for full configuration reference and architecture diagram.
55
64
 
56
- **Version:** 1.3.1
65
+ **Version:** 1.6.1
package/index.ts CHANGED
@@ -88,6 +88,8 @@ let grokContext: BrowserContext | null = null;
88
88
  // Persistent profile dirs — survive gateway restarts, keep cookies intact
89
89
  const GROK_PROFILE_DIR = join(homedir(), ".openclaw", "grok-profile");
90
90
  const GEMINI_PROFILE_DIR = join(homedir(), ".openclaw", "gemini-profile");
91
+ const CLAUDE_PROFILE_DIR = join(homedir(), ".openclaw", "claude-profile");
92
+ const CHATGPT_PROFILE_DIR = join(homedir(), ".openclaw", "chatgpt-profile");
91
93
 
92
94
  // Stealth launch options — prevent Cloudflare/bot detection from flagging the browser
93
95
  const STEALTH_ARGS = [
@@ -102,6 +104,14 @@ const STEALTH_IGNORE_DEFAULTS = ["--enable-automation"] as const;
102
104
  let geminiContext: BrowserContext | null = null;
103
105
  const GEMINI_EXPIRY_FILE = join(homedir(), ".openclaw", "gemini-cookie-expiry.json");
104
106
 
107
+ // ── Claude web-session state ─────────────────────────────────────────────────
108
+ let claudeWebContext: BrowserContext | null = null;
109
+ const CLAUDE_EXPIRY_FILE = join(homedir(), ".openclaw", "claude-cookie-expiry.json");
110
+
111
+ // ── ChatGPT web-session state ────────────────────────────────────────────────
112
+ let chatgptContext: BrowserContext | null = null;
113
+ const CHATGPT_EXPIRY_FILE = join(homedir(), ".openclaw", "chatgpt-cookie-expiry.json");
114
+
105
115
  interface GeminiExpiryInfo { expiresAt: number; loginAt: number; cookieName: string; }
106
116
 
107
117
  function saveGeminiExpiry(info: GeminiExpiryInfo): void {
@@ -128,6 +138,60 @@ async function scanGeminiCookieExpiry(ctx: BrowserContext): Promise<GeminiExpiry
128
138
  return { expiresAt: (earliest.expires ?? 0) * 1000, loginAt: Date.now(), cookieName: earliest.name };
129
139
  } catch { return null; }
130
140
  }
141
+ // ── Claude cookie expiry helpers ─────────────────────────────────────────────
142
+ interface ClaudeExpiryInfo { expiresAt: number; loginAt: number; cookieName: string; }
143
+ function saveClaudeExpiry(info: ClaudeExpiryInfo): void {
144
+ try { writeFileSync(CLAUDE_EXPIRY_FILE, JSON.stringify(info, null, 2)); } catch { /* ignore */ }
145
+ }
146
+ function loadClaudeExpiry(): ClaudeExpiryInfo | null {
147
+ try { return JSON.parse(readFileSync(CLAUDE_EXPIRY_FILE, "utf-8")) as ClaudeExpiryInfo; } catch { return null; }
148
+ }
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
+ }
157
+ async function scanClaudeCookieExpiry(ctx: BrowserContext): Promise<ClaudeExpiryInfo | null> {
158
+ try {
159
+ const cookies = await ctx.cookies(["https://claude.ai"]);
160
+ const auth = cookies.filter(c => ["sessionKey", "__cf_bm", "lastActiveOrg"].includes(c.name) && c.expires && c.expires > 0);
161
+ if (!auth.length) return null;
162
+ auth.sort((a, b) => (a.expires ?? 0) - (b.expires ?? 0));
163
+ const earliest = auth[0];
164
+ return { expiresAt: (earliest.expires ?? 0) * 1000, loginAt: Date.now(), cookieName: earliest.name };
165
+ } catch { return null; }
166
+ }
167
+
168
+ // ── ChatGPT cookie expiry helpers ────────────────────────────────────────────
169
+ interface ChatGPTExpiryInfo { expiresAt: number; loginAt: number; cookieName: string; }
170
+ function saveChatGPTExpiry(info: ChatGPTExpiryInfo): void {
171
+ try { writeFileSync(CHATGPT_EXPIRY_FILE, JSON.stringify(info, null, 2)); } catch { /* ignore */ }
172
+ }
173
+ function loadChatGPTExpiry(): ChatGPTExpiryInfo | null {
174
+ try { return JSON.parse(readFileSync(CHATGPT_EXPIRY_FILE, "utf-8")) as ChatGPTExpiryInfo; } catch { return null; }
175
+ }
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
+ }
184
+ async function scanChatGPTCookieExpiry(ctx: BrowserContext): Promise<ChatGPTExpiryInfo | null> {
185
+ try {
186
+ const cookies = await ctx.cookies(["https://chatgpt.com", "https://auth0.openai.com"]);
187
+ const auth = cookies.filter(c => ["__Secure-next-auth.session-token", "_puid", "oai-did"].includes(c.name) && c.expires && c.expires > 0);
188
+ if (!auth.length) return null;
189
+ auth.sort((a, b) => (a.expires ?? 0) - (b.expires ?? 0));
190
+ const earliest = auth[0];
191
+ return { expiresAt: (earliest.expires ?? 0) * 1000, loginAt: Date.now(), cookieName: earliest.name };
192
+ } catch { return null; }
193
+ }
194
+
131
195
  // ─────────────────────────────────────────────────────────────────────────────
132
196
 
133
197
  // Cookie expiry tracking file — written on /grok-login, read on startup
@@ -297,6 +361,80 @@ async function getOrLaunchGeminiContext(
297
361
  return _geminiLaunchPromise;
298
362
  }
299
363
 
364
+ // ── Per-provider persistent context launch promises (Claude) ─────────────────
365
+ let _claudeLaunchPromise: Promise<BrowserContext | null> | null = null;
366
+
367
+ async function getOrLaunchClaudeContext(
368
+ log: (msg: string) => void
369
+ ): Promise<BrowserContext | null> {
370
+ if (claudeWebContext) {
371
+ try { claudeWebContext.pages(); return claudeWebContext; } catch { claudeWebContext = null; }
372
+ }
373
+ const cdpCtx = await connectToOpenClawBrowser(log);
374
+ if (cdpCtx) return cdpCtx;
375
+ if (_claudeLaunchPromise) return _claudeLaunchPromise;
376
+ _claudeLaunchPromise = (async () => {
377
+ const { chromium } = await import("playwright");
378
+ log("[cli-bridge:claude-web] launching persistent Chromium…");
379
+ try {
380
+ mkdirSync(CLAUDE_PROFILE_DIR, { recursive: true });
381
+ const ctx = await chromium.launchPersistentContext(CLAUDE_PROFILE_DIR, {
382
+ headless: true,
383
+ channel: "chrome",
384
+ args: STEALTH_ARGS,
385
+ ignoreDefaultArgs: [...STEALTH_IGNORE_DEFAULTS],
386
+ });
387
+ claudeWebContext = ctx;
388
+ ctx.on("close", () => { claudeWebContext = null; log("[cli-bridge:claude-web] persistent context closed"); });
389
+ log("[cli-bridge:claude-web] persistent context ready");
390
+ return ctx;
391
+ } catch (err) {
392
+ log(`[cli-bridge:claude-web] failed to launch browser: ${(err as Error).message}`);
393
+ return null;
394
+ } finally {
395
+ _claudeLaunchPromise = null;
396
+ }
397
+ })();
398
+ return _claudeLaunchPromise;
399
+ }
400
+
401
+ // ── Per-provider persistent context launch promises (ChatGPT) ────────────────
402
+ let _chatgptLaunchPromise: Promise<BrowserContext | null> | null = null;
403
+
404
+ async function getOrLaunchChatGPTContext(
405
+ log: (msg: string) => void
406
+ ): Promise<BrowserContext | null> {
407
+ if (chatgptContext) {
408
+ try { chatgptContext.pages(); return chatgptContext; } catch { chatgptContext = null; }
409
+ }
410
+ const cdpCtx = await connectToOpenClawBrowser(log);
411
+ if (cdpCtx) return cdpCtx;
412
+ if (_chatgptLaunchPromise) return _chatgptLaunchPromise;
413
+ _chatgptLaunchPromise = (async () => {
414
+ const { chromium } = await import("playwright");
415
+ log("[cli-bridge:chatgpt] launching persistent Chromium…");
416
+ try {
417
+ mkdirSync(CHATGPT_PROFILE_DIR, { recursive: true });
418
+ const ctx = await chromium.launchPersistentContext(CHATGPT_PROFILE_DIR, {
419
+ headless: true,
420
+ channel: "chrome",
421
+ args: STEALTH_ARGS,
422
+ ignoreDefaultArgs: [...STEALTH_IGNORE_DEFAULTS],
423
+ });
424
+ chatgptContext = ctx;
425
+ ctx.on("close", () => { chatgptContext = null; log("[cli-bridge:chatgpt] persistent context closed"); });
426
+ log("[cli-bridge:chatgpt] persistent context ready");
427
+ return ctx;
428
+ } catch (err) {
429
+ log(`[cli-bridge:chatgpt] failed to launch browser: ${(err as Error).message}`);
430
+ return null;
431
+ } finally {
432
+ _chatgptLaunchPromise = null;
433
+ }
434
+ })();
435
+ return _chatgptLaunchPromise;
436
+ }
437
+
300
438
  /** Clean up all browser resources — call on plugin teardown */
301
439
  async function cleanupBrowsers(log: (msg: string) => void): Promise<void> {
302
440
  if (grokContext) {
@@ -307,6 +445,14 @@ async function cleanupBrowsers(log: (msg: string) => void): Promise<void> {
307
445
  try { await geminiContext.close(); } catch { /* ignore */ }
308
446
  geminiContext = null;
309
447
  }
448
+ if (claudeWebContext) {
449
+ try { await claudeWebContext.close(); } catch { /* ignore */ }
450
+ claudeWebContext = null;
451
+ }
452
+ if (chatgptContext) {
453
+ try { await chatgptContext.close(); } catch { /* ignore */ }
454
+ chatgptContext = null;
455
+ }
310
456
  if (_cdpBrowser) {
311
457
  try { await _cdpBrowser.close(); } catch { /* ignore */ }
312
458
  _cdpBrowser = null;
@@ -708,11 +854,12 @@ function proxyTestRequest(
708
854
  const plugin = {
709
855
  id: "openclaw-cli-bridge-elvatis",
710
856
  name: "OpenClaw CLI Bridge",
711
- version: "1.3.1",
857
+ version: "1.6.1",
712
858
  description:
713
859
  "Phase 1: openai-codex auth bridge. " +
714
860
  "Phase 2: HTTP proxy for gemini/claude CLIs. " +
715
- "Phase 3: /cli-* model switching, /cli-back restore, /cli-test health check.",
861
+ "Phase 3: /cli-* model switching, /cli-back restore, /cli-test health check. " +
862
+ "Phase 4: persistent browser profiles for Grok, Gemini, Claude.ai, ChatGPT.",
716
863
 
717
864
  register(api: OpenClawPluginApi) {
718
865
  const cfg = (api.pluginConfig ?? {}) as CliPluginConfig;
@@ -738,12 +885,16 @@ const plugin = {
738
885
  const { chromium } = await import("playwright");
739
886
  const { existsSync } = await import("node:fs");
740
887
 
888
+ // Collect providers that need re-login — send one batched WhatsApp alert
889
+ const needsLogin: string[] = [];
890
+
741
891
  const profileProviders: Array<{
742
892
  name: string;
743
893
  profileDir: string;
744
894
  cookieFile: string;
745
895
  verifySelector: string;
746
896
  homeUrl: string;
897
+ loginCmd: string;
747
898
  setCtx: (c: BrowserContext) => void;
748
899
  getCtx: () => BrowserContext | null;
749
900
  }> = [
@@ -753,18 +904,40 @@ const plugin = {
753
904
  cookieFile: join(homedir(), ".openclaw", "grok-session.json"),
754
905
  verifySelector: "textarea",
755
906
  homeUrl: "https://grok.com",
907
+ loginCmd: "/grok-login",
756
908
  getCtx: () => grokContext,
757
909
  setCtx: (c) => { grokContext = c; },
758
910
  },
759
911
  {
760
912
  name: "gemini",
761
- profileDir: join(homedir(), ".openclaw", "gemini-profile"),
913
+ profileDir: GEMINI_PROFILE_DIR,
762
914
  cookieFile: join(homedir(), ".openclaw", "gemini-cookie-expiry.json"),
763
915
  verifySelector: ".ql-editor",
764
916
  homeUrl: "https://gemini.google.com/app",
917
+ loginCmd: "/gemini-login",
765
918
  getCtx: () => geminiContext,
766
919
  setCtx: (c) => { geminiContext = c; },
767
920
  },
921
+ {
922
+ name: "claude-web",
923
+ profileDir: CLAUDE_PROFILE_DIR,
924
+ cookieFile: CLAUDE_EXPIRY_FILE,
925
+ verifySelector: ".ProseMirror",
926
+ homeUrl: "https://claude.ai/new",
927
+ loginCmd: "/claude-login",
928
+ getCtx: () => claudeWebContext,
929
+ setCtx: (c) => { claudeWebContext = c; },
930
+ },
931
+ {
932
+ name: "chatgpt",
933
+ profileDir: CHATGPT_PROFILE_DIR,
934
+ cookieFile: CHATGPT_EXPIRY_FILE,
935
+ verifySelector: "#prompt-textarea",
936
+ homeUrl: "https://chatgpt.com",
937
+ loginCmd: "/chatgpt-login",
938
+ getCtx: () => chatgptContext,
939
+ setCtx: (c) => { chatgptContext = c; },
940
+ },
768
941
  ];
769
942
 
770
943
  for (const p of profileProviders) {
@@ -791,7 +964,8 @@ const plugin = {
791
964
  api.logger.info(`[cli-bridge:${p.name}] session restored from profile ✅`);
792
965
  } else {
793
966
  await ctx.close().catch(() => {});
794
- api.logger.info(`[cli-bridge:${p.name}] profile exists but not logged in — needs /xxx-login`);
967
+ api.logger.info(`[cli-bridge:${p.name}] profile exists but not logged in — needs ${p.loginCmd}`);
968
+ needsLogin.push(p.loginCmd);
795
969
  }
796
970
  } catch (err) {
797
971
  api.logger.warn(`[cli-bridge:${p.name}] startup restore failed: ${(err as Error).message}`);
@@ -799,6 +973,22 @@ const plugin = {
799
973
 
800
974
  // Sequential — never spawn all 4 Chromium instances at once
801
975
  await new Promise(r => setTimeout(r, 3000));
976
+
977
+ }
978
+
979
+ // Send one batched WhatsApp alert if any providers need re-login
980
+ if (needsLogin.length > 0) {
981
+ const cmds = needsLogin.map(cmd => `• ${cmd}`).join("\n");
982
+ const msg = `🔐 *cli-bridge:* Session expired for ${needsLogin.length} provider(s). Run to re-login:\n\n${cmds}`;
983
+ try {
984
+ await api.runtime.system.runCommandWithTimeout(
985
+ ["openclaw", "message", "send", "--channel", "whatsapp", "--to", "+4915170113694", "--message", msg],
986
+ { timeoutMs: 10_000 }
987
+ );
988
+ api.logger.info(`[cli-bridge] sent re-login notification for: ${needsLogin.join(", ")}`);
989
+ } catch (err) {
990
+ api.logger.warn(`[cli-bridge] failed to send re-login notification: ${(err as Error).message}`);
991
+ }
802
992
  }
803
993
  })();
804
994
  }
@@ -843,7 +1033,7 @@ const plugin = {
843
1033
  },
844
1034
  ],
845
1035
 
846
- refreshOAuth: async (cred) => {
1036
+ refreshOAuth: async (cred: ProviderAuthContext) => {
847
1037
  try {
848
1038
  const fresh = await readCodexCredentials(codexAuthPath);
849
1039
  return {
@@ -918,6 +1108,28 @@ const plugin = {
918
1108
  }
919
1109
  return geminiContext;
920
1110
  },
1111
+ getClaudeContext: () => claudeWebContext,
1112
+ connectClaudeContext: async () => {
1113
+ const ctx = await getOrLaunchClaudeContext((msg) => api.logger.info(msg));
1114
+ if (ctx) {
1115
+ const { getOrCreateClaudePage } = await import("./src/claude-browser.js");
1116
+ const { page } = await getOrCreateClaudePage(ctx);
1117
+ const editor = await page.locator(".ProseMirror").isVisible().catch(() => false);
1118
+ if (editor) { claudeWebContext = ctx; return ctx; }
1119
+ }
1120
+ return claudeWebContext;
1121
+ },
1122
+ getChatGPTContext: () => chatgptContext,
1123
+ connectChatGPTContext: async () => {
1124
+ const ctx = await getOrLaunchChatGPTContext((msg) => api.logger.info(msg));
1125
+ if (ctx) {
1126
+ const { getOrCreateChatGPTPage } = await import("./src/chatgpt-browser.js");
1127
+ const { page } = await getOrCreateChatGPTPage(ctx);
1128
+ const editor = await page.locator("#prompt-textarea").isVisible().catch(() => false);
1129
+ if (editor) { chatgptContext = ctx; return ctx; }
1130
+ }
1131
+ return chatgptContext;
1132
+ },
921
1133
  });
922
1134
  proxyServer = server;
923
1135
  api.logger.info(
@@ -959,9 +1171,30 @@ const plugin = {
959
1171
  const editor = await page.locator(".ql-editor").isVisible().catch(() => false);
960
1172
  if (editor) { geminiContext = ctx; return ctx; }
961
1173
  }
962
- // No fallback spawn — return existing context or null to avoid Chromium leak
963
1174
  return geminiContext;
964
1175
  },
1176
+ getClaudeContext: () => claudeWebContext,
1177
+ connectClaudeContext: async () => {
1178
+ const ctx = await connectToOpenClawBrowser((msg) => api.logger.info(msg));
1179
+ if (ctx) {
1180
+ const { getOrCreateClaudePage } = await import("./src/claude-browser.js");
1181
+ const { page } = await getOrCreateClaudePage(ctx);
1182
+ const editor = await page.locator(".ProseMirror").isVisible().catch(() => false);
1183
+ if (editor) { claudeWebContext = ctx; return ctx; }
1184
+ }
1185
+ return claudeWebContext;
1186
+ },
1187
+ getChatGPTContext: () => chatgptContext,
1188
+ connectChatGPTContext: async () => {
1189
+ const ctx = await connectToOpenClawBrowser((msg) => api.logger.info(msg));
1190
+ if (ctx) {
1191
+ const { getOrCreateChatGPTPage } = await import("./src/chatgpt-browser.js");
1192
+ const { page } = await getOrCreateChatGPTPage(ctx);
1193
+ const editor = await page.locator("#prompt-textarea").isVisible().catch(() => false);
1194
+ if (editor) { chatgptContext = ctx; return ctx; }
1195
+ }
1196
+ return chatgptContext;
1197
+ },
965
1198
  });
966
1199
  proxyServer = server;
967
1200
  api.logger.info(`[cli-bridge] proxy ready on :${port} (retry)`);
@@ -1428,10 +1661,262 @@ const plugin = {
1428
1661
  },
1429
1662
  } satisfies OpenClawPluginCommandDefinition);
1430
1663
 
1664
+ // ── Claude.ai web-session commands ─────────────────────────────────────────
1665
+ api.registerCommand({
1666
+ name: "claude-login",
1667
+ description: "Authenticate claude.ai: imports cookies from OpenClaw browser into persistent profile",
1668
+ handler: async (): Promise<PluginCommandResult> => {
1669
+ if (claudeWebContext) {
1670
+ const { getOrCreateClaudePage } = await import("./src/claude-browser.js");
1671
+ try {
1672
+ const { page } = await getOrCreateClaudePage(claudeWebContext);
1673
+ const editor = await page.locator(".ProseMirror").isVisible().catch(() => false);
1674
+ if (editor) return { text: "✅ Already connected to claude.ai. Use `/claude-logout` first to reset." };
1675
+ } catch { /* fall through */ }
1676
+ claudeWebContext = null;
1677
+ }
1678
+
1679
+ api.logger.info("[cli-bridge:claude-web] /claude-login: connecting…");
1680
+
1681
+ // Step 1: try to grab cookies from OpenClaw browser (CDP)
1682
+ let importedCookies: unknown[] = [];
1683
+ try {
1684
+ const { chromium } = await import("playwright");
1685
+ const ocBrowser = await chromium.connectOverCDP("http://127.0.0.1:18800", { timeout: 3000 });
1686
+ const ocCtx = ocBrowser.contexts()[0];
1687
+ if (ocCtx) {
1688
+ importedCookies = await ocCtx.cookies(["https://claude.ai"]);
1689
+ api.logger.info(`[cli-bridge:claude-web] imported ${importedCookies.length} cookies from OpenClaw browser`);
1690
+ }
1691
+ await ocBrowser.close().catch(() => {});
1692
+ } catch {
1693
+ api.logger.info("[cli-bridge:claude-web] OpenClaw browser not available — using saved profile");
1694
+ }
1695
+
1696
+ // Step 2: get or launch persistent context
1697
+ const ctx = await getOrLaunchClaudeContext((msg) => api.logger.info(msg));
1698
+ if (!ctx) return { text: "❌ Could not launch browser. Check server logs." };
1699
+
1700
+ // Step 3: inject imported cookies
1701
+ if (importedCookies.length > 0) {
1702
+ await ctx.addCookies(importedCookies as Parameters<typeof ctx.addCookies>[0]);
1703
+ api.logger.info("[cli-bridge:claude-web] cookies injected into persistent profile");
1704
+ }
1705
+
1706
+ // Step 4: navigate and verify
1707
+ const { getOrCreateClaudePage } = await import("./src/claude-browser.js");
1708
+ let page;
1709
+ try {
1710
+ ({ page } = await getOrCreateClaudePage(ctx));
1711
+ } catch (err) {
1712
+ return { text: `❌ Failed to open claude.ai: ${(err as Error).message}` };
1713
+ }
1714
+
1715
+ let editor = await page.locator(".ProseMirror").isVisible().catch(() => false);
1716
+ if (!editor) {
1717
+ // Headless failed — launch headed browser for interactive login
1718
+ api.logger.info("[cli-bridge:claude-web] headless login failed — launching headed browser for manual login…");
1719
+ try { await ctx.close(); } catch { /* ignore */ }
1720
+ claudeWebContext = null;
1721
+
1722
+ const { chromium } = await import("playwright");
1723
+ const headedCtx = await chromium.launchPersistentContext(CLAUDE_PROFILE_DIR, {
1724
+ headless: false,
1725
+ channel: "chrome",
1726
+ args: STEALTH_ARGS,
1727
+ ignoreDefaultArgs: [...STEALTH_IGNORE_DEFAULTS],
1728
+ });
1729
+ const loginPage = await headedCtx.newPage();
1730
+ await loginPage.goto("https://claude.ai/new", { waitUntil: "domcontentloaded", timeout: 15_000 });
1731
+
1732
+ api.logger.info("[cli-bridge:claude-web] waiting for manual login (5 min timeout)…");
1733
+ try {
1734
+ await loginPage.waitForSelector(".ProseMirror", { timeout: 300_000 });
1735
+ } catch {
1736
+ await headedCtx.close().catch(() => {});
1737
+ return { text: "❌ Login timeout — Claude editor did not appear within 5 minutes." };
1738
+ }
1739
+
1740
+ claudeWebContext = headedCtx;
1741
+ headedCtx.on("close", () => { claudeWebContext = null; });
1742
+ editor = true;
1743
+ page = loginPage;
1744
+ } else {
1745
+ claudeWebContext = ctx;
1746
+ }
1747
+
1748
+ const expiry = await scanClaudeCookieExpiry(claudeWebContext!);
1749
+ if (expiry) {
1750
+ saveClaudeExpiry(expiry);
1751
+ api.logger.info(`[cli-bridge:claude-web] cookie expiry: ${new Date(expiry.expiresAt).toISOString()}`);
1752
+ }
1753
+ const expiryLine = expiry ? `\n\n🕐 Cookie expiry: ${formatClaudeExpiry(expiry)}` : "";
1754
+
1755
+ return { text: `✅ Claude.ai session ready!\n\nModels available:\n• \`vllm/web-claude/claude-sonnet\`\n• \`vllm/web-claude/claude-opus\`\n• \`vllm/web-claude/claude-haiku\`${expiryLine}` };
1756
+ },
1757
+ } satisfies OpenClawPluginCommandDefinition);
1758
+
1759
+ api.registerCommand({
1760
+ name: "claude-status",
1761
+ description: "Check claude.ai session status",
1762
+ handler: async (): Promise<PluginCommandResult> => {
1763
+ if (!claudeWebContext) {
1764
+ return { text: "❌ No active claude.ai session\nRun `/claude-login` to authenticate." };
1765
+ }
1766
+ const { getOrCreateClaudePage } = await import("./src/claude-browser.js");
1767
+ try {
1768
+ const { page } = await getOrCreateClaudePage(claudeWebContext);
1769
+ const editor = await page.locator(".ProseMirror").isVisible().catch(() => false);
1770
+ if (editor) {
1771
+ const expiry = loadClaudeExpiry();
1772
+ const expiryLine = expiry ? `\n🕐 ${formatClaudeExpiry(expiry)}` : "";
1773
+ return { text: `✅ claude.ai session active\nProxy: \`127.0.0.1:${port}\`\nModels: web-claude/claude-sonnet, claude-opus, claude-haiku${expiryLine}` };
1774
+ }
1775
+ } catch { /* fall through */ }
1776
+ claudeWebContext = null;
1777
+ return { text: "❌ Session lost — run `/claude-login` to re-authenticate." };
1778
+ },
1779
+ } satisfies OpenClawPluginCommandDefinition);
1780
+
1781
+ api.registerCommand({
1782
+ name: "claude-logout",
1783
+ description: "Disconnect from claude.ai session",
1784
+ handler: async (): Promise<PluginCommandResult> => {
1785
+ claudeWebContext = null;
1786
+ return { text: "✅ Disconnected from claude.ai. Run `/claude-login` to reconnect." };
1787
+ },
1788
+ } satisfies OpenClawPluginCommandDefinition);
1789
+
1790
+ // ── ChatGPT web-session commands ─────────────────────────────────────────
1791
+ api.registerCommand({
1792
+ name: "chatgpt-login",
1793
+ description: "Authenticate chatgpt.com: imports cookies from OpenClaw browser into persistent profile",
1794
+ handler: async (): Promise<PluginCommandResult> => {
1795
+ if (chatgptContext) {
1796
+ const { getOrCreateChatGPTPage } = await import("./src/chatgpt-browser.js");
1797
+ try {
1798
+ const { page } = await getOrCreateChatGPTPage(chatgptContext);
1799
+ const editor = await page.locator("#prompt-textarea").isVisible().catch(() => false);
1800
+ if (editor) return { text: "✅ Already connected to chatgpt.com. Use `/chatgpt-logout` first to reset." };
1801
+ } catch { /* fall through */ }
1802
+ chatgptContext = null;
1803
+ }
1804
+
1805
+ api.logger.info("[cli-bridge:chatgpt] /chatgpt-login: connecting…");
1806
+
1807
+ // Step 1: try to grab cookies from OpenClaw browser (CDP)
1808
+ let importedCookies: unknown[] = [];
1809
+ try {
1810
+ const { chromium } = await import("playwright");
1811
+ const ocBrowser = await chromium.connectOverCDP("http://127.0.0.1:18800", { timeout: 3000 });
1812
+ const ocCtx = ocBrowser.contexts()[0];
1813
+ if (ocCtx) {
1814
+ importedCookies = await ocCtx.cookies(["https://chatgpt.com", "https://auth0.openai.com", "https://openai.com"]);
1815
+ api.logger.info(`[cli-bridge:chatgpt] imported ${importedCookies.length} cookies from OpenClaw browser`);
1816
+ }
1817
+ await ocBrowser.close().catch(() => {});
1818
+ } catch {
1819
+ api.logger.info("[cli-bridge:chatgpt] OpenClaw browser not available — using saved profile");
1820
+ }
1821
+
1822
+ // Step 2: get or launch persistent context
1823
+ const ctx = await getOrLaunchChatGPTContext((msg) => api.logger.info(msg));
1824
+ if (!ctx) return { text: "❌ Could not launch browser. Check server logs." };
1825
+
1826
+ // Step 3: inject imported cookies
1827
+ if (importedCookies.length > 0) {
1828
+ await ctx.addCookies(importedCookies as Parameters<typeof ctx.addCookies>[0]);
1829
+ api.logger.info("[cli-bridge:chatgpt] cookies injected into persistent profile");
1830
+ }
1831
+
1832
+ // Step 4: navigate and verify
1833
+ const { getOrCreateChatGPTPage } = await import("./src/chatgpt-browser.js");
1834
+ let page;
1835
+ try {
1836
+ ({ page } = await getOrCreateChatGPTPage(ctx));
1837
+ } catch (err) {
1838
+ return { text: `❌ Failed to open chatgpt.com: ${(err as Error).message}` };
1839
+ }
1840
+
1841
+ let editor = await page.locator("#prompt-textarea").isVisible().catch(() => false);
1842
+ if (!editor) {
1843
+ // Headless failed — launch headed browser for interactive login
1844
+ api.logger.info("[cli-bridge:chatgpt] headless login failed — launching headed browser for manual login…");
1845
+ try { await ctx.close(); } catch { /* ignore */ }
1846
+ chatgptContext = null;
1847
+
1848
+ const { chromium } = await import("playwright");
1849
+ const headedCtx = await chromium.launchPersistentContext(CHATGPT_PROFILE_DIR, {
1850
+ headless: false,
1851
+ channel: "chrome",
1852
+ args: STEALTH_ARGS,
1853
+ ignoreDefaultArgs: [...STEALTH_IGNORE_DEFAULTS],
1854
+ });
1855
+ const loginPage = await headedCtx.newPage();
1856
+ await loginPage.goto("https://chatgpt.com", { waitUntil: "domcontentloaded", timeout: 15_000 });
1857
+
1858
+ api.logger.info("[cli-bridge:chatgpt] waiting for manual login (5 min timeout)…");
1859
+ try {
1860
+ await loginPage.waitForSelector("#prompt-textarea", { timeout: 300_000 });
1861
+ } catch {
1862
+ await headedCtx.close().catch(() => {});
1863
+ return { text: "❌ Login timeout — ChatGPT editor did not appear within 5 minutes." };
1864
+ }
1865
+
1866
+ chatgptContext = headedCtx;
1867
+ headedCtx.on("close", () => { chatgptContext = null; });
1868
+ editor = true;
1869
+ page = loginPage;
1870
+ } else {
1871
+ chatgptContext = ctx;
1872
+ }
1873
+
1874
+ const expiry = await scanChatGPTCookieExpiry(chatgptContext!);
1875
+ if (expiry) {
1876
+ saveChatGPTExpiry(expiry);
1877
+ api.logger.info(`[cli-bridge:chatgpt] cookie expiry: ${new Date(expiry.expiresAt).toISOString()}`);
1878
+ }
1879
+ const expiryLine = expiry ? `\n\n🕐 Cookie expiry: ${formatChatGPTExpiry(expiry)}` : "";
1880
+
1881
+ return { text: `✅ ChatGPT session ready!\n\nModels available:\n• \`vllm/web-chatgpt/gpt-4o\`\n• \`vllm/web-chatgpt/gpt-4o-mini\`\n• \`vllm/web-chatgpt/gpt-o3\`\n• \`vllm/web-chatgpt/gpt-o4-mini\`\n• \`vllm/web-chatgpt/gpt-5\`${expiryLine}` };
1882
+ },
1883
+ } satisfies OpenClawPluginCommandDefinition);
1884
+
1885
+ api.registerCommand({
1886
+ name: "chatgpt-status",
1887
+ description: "Check chatgpt.com session status",
1888
+ handler: async (): Promise<PluginCommandResult> => {
1889
+ if (!chatgptContext) {
1890
+ return { text: "❌ No active chatgpt.com session\nRun `/chatgpt-login` to authenticate." };
1891
+ }
1892
+ const { getOrCreateChatGPTPage } = await import("./src/chatgpt-browser.js");
1893
+ try {
1894
+ const { page } = await getOrCreateChatGPTPage(chatgptContext);
1895
+ const editor = await page.locator("#prompt-textarea").isVisible().catch(() => false);
1896
+ if (editor) {
1897
+ const expiry = loadChatGPTExpiry();
1898
+ const expiryLine = expiry ? `\n🕐 ${formatChatGPTExpiry(expiry)}` : "";
1899
+ return { text: `✅ chatgpt.com session active\nProxy: \`127.0.0.1:${port}\`\nModels: web-chatgpt/gpt-4o, gpt-4o-mini, gpt-o3, gpt-o4-mini, gpt-5${expiryLine}` };
1900
+ }
1901
+ } catch { /* fall through */ }
1902
+ chatgptContext = null;
1903
+ return { text: "❌ Session lost — run `/chatgpt-login` to re-authenticate." };
1904
+ },
1905
+ } satisfies OpenClawPluginCommandDefinition);
1906
+
1907
+ api.registerCommand({
1908
+ name: "chatgpt-logout",
1909
+ description: "Disconnect from chatgpt.com session",
1910
+ handler: async (): Promise<PluginCommandResult> => {
1911
+ chatgptContext = null;
1912
+ return { text: "✅ Disconnected from chatgpt.com. Run `/chatgpt-login` to reconnect." };
1913
+ },
1914
+ } satisfies OpenClawPluginCommandDefinition);
1915
+
1431
1916
  // ── /bridge-status — all providers at a glance ───────────────────────────
1432
1917
  api.registerCommand({
1433
1918
  name: "bridge-status",
1434
- description: "Show status of all headless browser providers (Grok, Gemini)",
1919
+ description: "Show status of all headless browser providers (Grok, Gemini, Claude, ChatGPT)",
1435
1920
  handler: async (): Promise<PluginCommandResult> => {
1436
1921
  const lines: string[] = [`🌉 *CLI Bridge v${plugin.version} — Provider Status*\n`];
1437
1922
 
@@ -1464,6 +1949,36 @@ const plugin = {
1464
1949
  loginCmd: "/gemini-login",
1465
1950
  expiry: () => { const e = loadGeminiExpiry(); return e ? formatGeminiExpiry(e) : null; },
1466
1951
  },
1952
+ {
1953
+ name: "Claude.ai",
1954
+ ctx: claudeWebContext,
1955
+ check: async () => {
1956
+ if (!claudeWebContext) return false;
1957
+ try {
1958
+ const { getOrCreateClaudePage } = await import("./src/claude-browser.js");
1959
+ const { page } = await getOrCreateClaudePage(claudeWebContext);
1960
+ return page.locator(".ProseMirror").isVisible().catch(() => false);
1961
+ } catch { claudeWebContext = null; return false; }
1962
+ },
1963
+ models: "web-claude/claude-sonnet, claude-opus, claude-haiku",
1964
+ loginCmd: "/claude-login",
1965
+ expiry: () => { const e = loadClaudeExpiry(); return e ? formatClaudeExpiry(e) : null; },
1966
+ },
1967
+ {
1968
+ name: "ChatGPT",
1969
+ ctx: chatgptContext,
1970
+ check: async () => {
1971
+ if (!chatgptContext) return false;
1972
+ try {
1973
+ const { getOrCreateChatGPTPage } = await import("./src/chatgpt-browser.js");
1974
+ const { page } = await getOrCreateChatGPTPage(chatgptContext);
1975
+ return page.locator("#prompt-textarea").isVisible().catch(() => false);
1976
+ } catch { chatgptContext = null; return false; }
1977
+ },
1978
+ models: "web-chatgpt/gpt-4o, gpt-4o-mini, gpt-o3, gpt-o4-mini, gpt-5",
1979
+ loginCmd: "/chatgpt-login",
1980
+ expiry: () => { const e = loadChatGPTExpiry(); return e ? formatChatGPTExpiry(e) : null; },
1981
+ },
1467
1982
  ];
1468
1983
 
1469
1984
  for (const c of checks) {
@@ -1497,6 +2012,12 @@ const plugin = {
1497
2012
  "/gemini-login",
1498
2013
  "/gemini-status",
1499
2014
  "/gemini-logout",
2015
+ "/claude-login",
2016
+ "/claude-status",
2017
+ "/claude-logout",
2018
+ "/chatgpt-login",
2019
+ "/chatgpt-status",
2020
+ "/chatgpt-logout",
1500
2021
  "/bridge-status",
1501
2022
  ];
1502
2023
  api.logger.info(`[cli-bridge] registered ${allCommands.length} commands: ${allCommands.join(", ")}`);
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "id": "openclaw-cli-bridge-elvatis",
3
3
  "name": "OpenClaw CLI Bridge",
4
- "version": "1.5.0",
4
+ "version": "1.6.1",
5
+ "license": "MIT",
5
6
  "description": "Phase 1: openai-codex auth bridge. Phase 2: local HTTP proxy routing model calls through gemini/claude CLIs (vllm provider).",
6
7
  "providers": [
7
8
  "openai-codex"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elvatis_com/openclaw-cli-bridge-elvatis",
3
- "version": "1.5.0",
3
+ "version": "1.6.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": {
@@ -9,7 +9,7 @@
9
9
  ]
10
10
  },
11
11
  "scripts": {
12
- "build": "tsc",
12
+ "build": "tsc --noEmitOnError false",
13
13
  "typecheck": "tsc -p tsconfig.check.json",
14
14
  "test": "vitest run",
15
15
  "ci": "npm run typecheck && npm run test"
@@ -14,6 +14,8 @@ import { type ChatMessage, routeToCliRunner } from "./cli-runner.js";
14
14
  import { scheduleTokenRefresh, setAuthLogger, stopTokenRefresh } from "./claude-auth.js";
15
15
  import { grokComplete, grokCompleteStream, type ChatMessage as GrokChatMessage } from "./grok-client.js";
16
16
  import { geminiComplete, geminiCompleteStream, type ChatMessage as GeminiBrowserChatMessage } from "./gemini-browser.js";
17
+ import { claudeComplete, claudeCompleteStream, type ChatMessage as ClaudeBrowserChatMessage } from "./claude-browser.js";
18
+ import { chatgptComplete, chatgptCompleteStream, type ChatMessage as ChatGPTBrowserChatMessage } from "./chatgpt-browser.js";
17
19
  import type { BrowserContext } from "playwright";
18
20
 
19
21
  export type GrokCompleteOptions = Parameters<typeof grokComplete>[1];
@@ -42,6 +44,22 @@ export interface ProxyServerOptions {
42
44
  _geminiComplete?: typeof geminiComplete;
43
45
  /** Override for testing — replaces geminiCompleteStream */
44
46
  _geminiCompleteStream?: typeof geminiCompleteStream;
47
+ /** Returns the current authenticated Claude BrowserContext (null if not logged in) */
48
+ getClaudeContext?: () => BrowserContext | null;
49
+ /** Async lazy connect — called when getClaudeContext returns null */
50
+ connectClaudeContext?: () => Promise<BrowserContext | null>;
51
+ /** Override for testing — replaces claudeComplete */
52
+ _claudeComplete?: typeof claudeComplete;
53
+ /** Override for testing — replaces claudeCompleteStream */
54
+ _claudeCompleteStream?: typeof claudeCompleteStream;
55
+ /** Returns the current authenticated ChatGPT BrowserContext (null if not logged in) */
56
+ getChatGPTContext?: () => BrowserContext | null;
57
+ /** Async lazy connect — called when getChatGPTContext returns null */
58
+ connectChatGPTContext?: () => Promise<BrowserContext | null>;
59
+ /** Override for testing — replaces chatgptComplete */
60
+ _chatgptComplete?: typeof chatgptComplete;
61
+ /** Override for testing — replaces chatgptCompleteStream */
62
+ _chatgptCompleteStream?: typeof chatgptCompleteStream;
45
63
  }
46
64
 
47
65
  /** Available CLI bridge models for GET /v1/models */
@@ -64,6 +82,16 @@ export const CLI_MODELS = [
64
82
  { id: "web-gemini/gemini-2-5-flash", name: "Gemini 2.5 Flash (web session)", contextWindow: 1_000_000, maxTokens: 8192 },
65
83
  { id: "web-gemini/gemini-3-pro", name: "Gemini 3 Pro (web session)", contextWindow: 1_000_000, maxTokens: 8192 },
66
84
  { id: "web-gemini/gemini-3-flash", name: "Gemini 3 Flash (web session)", contextWindow: 1_000_000, maxTokens: 8192 },
85
+ // Claude web-session models (requires /claude-login)
86
+ { id: "web-claude/claude-sonnet", name: "Claude Sonnet (web session)", contextWindow: 200_000, maxTokens: 8192 },
87
+ { id: "web-claude/claude-opus", name: "Claude Opus (web session)", contextWindow: 200_000, maxTokens: 8192 },
88
+ { id: "web-claude/claude-haiku", name: "Claude Haiku (web session)", contextWindow: 200_000, maxTokens: 8192 },
89
+ // ChatGPT web-session models (requires /chatgpt-login)
90
+ { id: "web-chatgpt/gpt-4o", name: "GPT-4o (web session)", contextWindow: 128_000, maxTokens: 8192 },
91
+ { id: "web-chatgpt/gpt-4o-mini", name: "GPT-4o Mini (web session)", contextWindow: 128_000, maxTokens: 8192 },
92
+ { id: "web-chatgpt/gpt-o3", name: "GPT o3 (web session)", contextWindow: 200_000, maxTokens: 8192 },
93
+ { id: "web-chatgpt/gpt-o4-mini", name: "GPT o4-mini (web session)", contextWindow: 200_000, maxTokens: 8192 },
94
+ { id: "web-chatgpt/gpt-5", name: "GPT-5 (web session)", contextWindow: 200_000, maxTokens: 8192 },
67
95
  ];
68
96
 
69
97
  // ──────────────────────────────────────────────────────────────────────────────
@@ -284,6 +312,105 @@ async function handleRequest(
284
312
  }
285
313
  // ─────────────────────────────────────────────────────────────────────────
286
314
 
315
+ // ── Claude web-session routing ────────────────────────────────────────────
316
+ if (model.startsWith("web-claude/")) {
317
+ let claudeCtx = opts.getClaudeContext?.() ?? null;
318
+ if (!claudeCtx && opts.connectClaudeContext) {
319
+ claudeCtx = await opts.connectClaudeContext();
320
+ }
321
+ if (!claudeCtx) {
322
+ res.writeHead(503, { "Content-Type": "application/json" });
323
+ res.end(JSON.stringify({ error: { message: "No active claude.ai session. Use /claude-login to authenticate.", code: "no_claude_session" } }));
324
+ return;
325
+ }
326
+ const timeoutMs = opts.timeoutMs ?? 120_000;
327
+ const claudeMessages = messages as ClaudeBrowserChatMessage[];
328
+ const doClaudeComplete = opts._claudeComplete ?? claudeComplete;
329
+ const doClaudeCompleteStream = opts._claudeCompleteStream ?? claudeCompleteStream;
330
+ try {
331
+ if (stream) {
332
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", ...corsHeaders() });
333
+ sendSseChunk(res, { id, created, model, delta: { role: "assistant" }, finish_reason: null });
334
+ const result = await doClaudeCompleteStream(
335
+ claudeCtx,
336
+ { messages: claudeMessages, model, timeoutMs },
337
+ (token) => sendSseChunk(res, { id, created, model, delta: { content: token }, finish_reason: null }),
338
+ opts.log
339
+ );
340
+ sendSseChunk(res, { id, created, model, delta: {}, finish_reason: result.finishReason });
341
+ res.write("data: [DONE]\n\n");
342
+ res.end();
343
+ } else {
344
+ const result = await doClaudeComplete(claudeCtx, { messages: claudeMessages, model, timeoutMs }, opts.log);
345
+ res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
346
+ res.end(JSON.stringify({
347
+ id, object: "chat.completion", created, model,
348
+ choices: [{ index: 0, message: { role: "assistant", content: result.content }, finish_reason: result.finishReason }],
349
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
350
+ }));
351
+ }
352
+ } catch (err) {
353
+ const msg = (err as Error).message;
354
+ opts.warn(`[cli-bridge] Claude browser error for ${model}: ${msg}`);
355
+ if (!res.headersSent) {
356
+ res.writeHead(500, { "Content-Type": "application/json" });
357
+ res.end(JSON.stringify({ error: { message: msg, type: "claude_browser_error" } }));
358
+ }
359
+ }
360
+ return;
361
+ }
362
+ // ─────────────────────────────────────────────────────────────────────────
363
+
364
+ // ── ChatGPT web-session routing ──────────────────────────────────────────
365
+ if (model.startsWith("web-chatgpt/")) {
366
+ let chatgptCtx = opts.getChatGPTContext?.() ?? null;
367
+ if (!chatgptCtx && opts.connectChatGPTContext) {
368
+ chatgptCtx = await opts.connectChatGPTContext();
369
+ }
370
+ if (!chatgptCtx) {
371
+ res.writeHead(503, { "Content-Type": "application/json" });
372
+ res.end(JSON.stringify({ error: { message: "No active chatgpt.com session. Use /chatgpt-login to authenticate.", code: "no_chatgpt_session" } }));
373
+ return;
374
+ }
375
+ const chatgptModel = model.replace("web-chatgpt/", "");
376
+ const timeoutMs = opts.timeoutMs ?? 120_000;
377
+ const chatgptMessages = messages as ChatGPTBrowserChatMessage[];
378
+ const doChatGPTComplete = opts._chatgptComplete ?? chatgptComplete;
379
+ const doChatGPTCompleteStream = opts._chatgptCompleteStream ?? chatgptCompleteStream;
380
+ try {
381
+ if (stream) {
382
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", ...corsHeaders() });
383
+ sendSseChunk(res, { id, created, model, delta: { role: "assistant" }, finish_reason: null });
384
+ const result = await doChatGPTCompleteStream(
385
+ chatgptCtx,
386
+ { messages: chatgptMessages, model: chatgptModel, timeoutMs },
387
+ (token) => sendSseChunk(res, { id, created, model, delta: { content: token }, finish_reason: null }),
388
+ opts.log
389
+ );
390
+ sendSseChunk(res, { id, created, model, delta: {}, finish_reason: result.finishReason });
391
+ res.write("data: [DONE]\n\n");
392
+ res.end();
393
+ } else {
394
+ const result = await doChatGPTComplete(chatgptCtx, { messages: chatgptMessages, model: chatgptModel, timeoutMs }, opts.log);
395
+ res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
396
+ res.end(JSON.stringify({
397
+ id, object: "chat.completion", created, model,
398
+ choices: [{ index: 0, message: { role: "assistant", content: result.content }, finish_reason: result.finishReason }],
399
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
400
+ }));
401
+ }
402
+ } catch (err) {
403
+ const msg = (err as Error).message;
404
+ opts.warn(`[cli-bridge] ChatGPT browser error for ${model}: ${msg}`);
405
+ if (!res.headersSent) {
406
+ res.writeHead(500, { "Content-Type": "application/json" });
407
+ res.end(JSON.stringify({ error: { message: msg, type: "chatgpt_browser_error" } }));
408
+ }
409
+ }
410
+ return;
411
+ }
412
+ // ─────────────────────────────────────────────────────────────────────────
413
+
287
414
  // ── CLI runner routing (Gemini / Claude Code) ─────────────────────────────
288
415
  let content: string;
289
416
  try {
@@ -0,0 +1,153 @@
1
+ /**
2
+ * test/chatgpt-proxy.test.ts
3
+ *
4
+ * Tests for ChatGPT web-session routing in the cli-bridge proxy.
5
+ * Uses _chatgptComplete/_chatgptCompleteStream DI overrides (no real browser).
6
+ */
7
+
8
+ import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
9
+ import http from "node:http";
10
+ import type { AddressInfo } from "node:net";
11
+ import { startProxyServer, CLI_MODELS } from "../src/proxy-server.js";
12
+ import type { BrowserContext } from "playwright";
13
+
14
+ type ChatGPTCompleteOptions = { messages: { role: string; content: string }[]; model?: string; timeoutMs?: number };
15
+ type ChatGPTCompleteResult = { content: string; model: string; finishReason: string };
16
+
17
+ const stubChatGPTComplete = vi.fn(async (
18
+ _ctx: BrowserContext,
19
+ opts: ChatGPTCompleteOptions,
20
+ _log: (msg: string) => void
21
+ ): Promise<ChatGPTCompleteResult> => ({
22
+ content: `chatgpt mock: ${opts.messages[opts.messages.length - 1]?.content ?? ""}`,
23
+ model: opts.model ?? "gpt-4o",
24
+ finishReason: "stop",
25
+ }));
26
+
27
+ const stubChatGPTCompleteStream = vi.fn(async (
28
+ _ctx: BrowserContext,
29
+ opts: ChatGPTCompleteOptions,
30
+ onToken: (t: string) => void,
31
+ _log: (msg: string) => void
32
+ ): Promise<ChatGPTCompleteResult> => {
33
+ const tokens = ["chatgpt ", "stream ", "mock"];
34
+ for (const t of tokens) onToken(t);
35
+ return { content: tokens.join(""), model: opts.model ?? "gpt-4o", finishReason: "stop" };
36
+ });
37
+
38
+ async function httpPost(url: string, body: unknown): Promise<{ status: number; body: unknown }> {
39
+ return new Promise((resolve, reject) => {
40
+ const data = JSON.stringify(body);
41
+ const u = new URL(url);
42
+ const req = http.request(
43
+ { hostname: u.hostname, port: Number(u.port), path: u.pathname, method: "POST",
44
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) } },
45
+ (res) => { let raw = ""; res.on("data", c => raw += c); res.on("end", () => { try { resolve({ status: res.statusCode ?? 0, body: JSON.parse(raw) }); } catch { resolve({ status: res.statusCode ?? 0, body: raw }); } }); }
46
+ );
47
+ req.on("error", reject); req.write(data); req.end();
48
+ });
49
+ }
50
+ async function httpGet(url: string): Promise<{ status: number; body: unknown }> {
51
+ return new Promise((resolve, reject) => {
52
+ const u = new URL(url);
53
+ const req = http.request({ hostname: u.hostname, port: Number(u.port), path: u.pathname, method: "GET" },
54
+ (res) => { let raw = ""; res.on("data", c => raw += c); res.on("end", () => { try { resolve({ status: res.statusCode ?? 0, body: JSON.parse(raw) }); } catch { resolve({ status: res.statusCode ?? 0, body: raw }); } }); }
55
+ );
56
+ req.on("error", reject); req.end();
57
+ });
58
+ }
59
+
60
+ const fakeCtx = {} as BrowserContext;
61
+ let server: http.Server;
62
+ let baseUrl: string;
63
+
64
+ beforeAll(async () => {
65
+ server = await startProxyServer({
66
+ port: 0, log: () => {}, warn: () => {},
67
+ getChatGPTContext: () => fakeCtx,
68
+ // @ts-expect-error — stub types close enough for testing
69
+ _chatgptComplete: stubChatGPTComplete,
70
+ // @ts-expect-error — stub types close enough for testing
71
+ _chatgptCompleteStream: stubChatGPTCompleteStream,
72
+ });
73
+ baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
74
+ });
75
+ afterAll(() => server.close());
76
+
77
+ describe("ChatGPT web-session routing — model list", () => {
78
+ it("includes web-chatgpt/* models in /v1/models", async () => {
79
+ const res = await httpGet(`${baseUrl}/v1/models`);
80
+ expect(res.status).toBe(200);
81
+ const ids = (res.body as { data: { id: string }[] }).data.map(m => m.id);
82
+ expect(ids).toContain("web-chatgpt/gpt-4o");
83
+ expect(ids).toContain("web-chatgpt/gpt-4o-mini");
84
+ expect(ids).toContain("web-chatgpt/gpt-o3");
85
+ expect(ids).toContain("web-chatgpt/gpt-o4-mini");
86
+ expect(ids).toContain("web-chatgpt/gpt-5");
87
+ });
88
+
89
+ it("web-chatgpt/* models listed in CLI_MODELS constant", () => {
90
+ const chatgpt = CLI_MODELS.filter(m => m.id.startsWith("web-chatgpt/"));
91
+ expect(chatgpt).toHaveLength(5);
92
+ });
93
+ });
94
+
95
+ describe("ChatGPT web-session routing — non-streaming", () => {
96
+ it("returns assistant message for web-chatgpt/gpt-4o", async () => {
97
+ const res = await httpPost(`${baseUrl}/v1/chat/completions`, {
98
+ model: "web-chatgpt/gpt-4o",
99
+ messages: [{ role: "user", content: "hello chatgpt" }],
100
+ stream: false,
101
+ });
102
+ expect(res.status).toBe(200);
103
+ const body = res.body as { choices: { message: { content: string } }[] };
104
+ expect(body.choices[0].message.content).toContain("chatgpt mock");
105
+ expect(body.choices[0].message.content).toContain("hello chatgpt");
106
+ });
107
+
108
+ it("strips web-chatgpt/ prefix before passing to chatgptComplete", async () => {
109
+ stubChatGPTComplete.mockClear();
110
+ await httpPost(`${baseUrl}/v1/chat/completions`, {
111
+ model: "web-chatgpt/gpt-o3",
112
+ messages: [{ role: "user", content: "test" }],
113
+ });
114
+ expect(stubChatGPTComplete).toHaveBeenCalledOnce();
115
+ expect(stubChatGPTComplete.mock.calls[0][1].model).toBe("gpt-o3");
116
+ });
117
+
118
+ it("response model preserves web-chatgpt/ prefix", async () => {
119
+ const res = await httpPost(`${baseUrl}/v1/chat/completions`, {
120
+ model: "web-chatgpt/gpt-5",
121
+ messages: [{ role: "user", content: "hi" }],
122
+ });
123
+ expect(res.status).toBe(200);
124
+ const body = res.body as { model: string };
125
+ expect(body.model).toBe("web-chatgpt/gpt-5");
126
+ });
127
+
128
+ it("returns 503 when no chatgpt context", async () => {
129
+ const s = await startProxyServer({ port: 0, log: () => {}, warn: () => {}, getChatGPTContext: () => null });
130
+ const url = `http://127.0.0.1:${(s.address() as AddressInfo).port}`;
131
+ const res = await httpPost(`${url}/v1/chat/completions`, {
132
+ model: "web-chatgpt/gpt-4o",
133
+ messages: [{ role: "user", content: "hi" }],
134
+ });
135
+ expect(res.status).toBe(503);
136
+ expect((res.body as { error: { code: string } }).error.code).toBe("no_chatgpt_session");
137
+ s.close();
138
+ });
139
+ });
140
+
141
+ describe("ChatGPT web-session routing — streaming", () => {
142
+ it("returns SSE stream", async () => {
143
+ return new Promise<void>((resolve, reject) => {
144
+ const body = JSON.stringify({ model: "web-chatgpt/gpt-4o", messages: [{ role: "user", content: "stream" }], stream: true });
145
+ const u = new URL(`${baseUrl}/v1/chat/completions`);
146
+ const req = http.request({ hostname: u.hostname, port: Number(u.port), path: u.pathname, method: "POST",
147
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) } },
148
+ (res) => { expect(res.statusCode).toBe(200); let raw = ""; res.on("data", c => raw += c); res.on("end", () => { expect(raw).toContain("[DONE]"); resolve(); }); }
149
+ );
150
+ req.on("error", reject); req.write(body); req.end();
151
+ });
152
+ });
153
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * test/claude-proxy.test.ts
3
+ *
4
+ * Tests for Claude web-session routing in the cli-bridge proxy.
5
+ * Uses _claudeComplete/_claudeCompleteStream DI overrides (no real browser).
6
+ */
7
+
8
+ import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
9
+ import http from "node:http";
10
+ import type { AddressInfo } from "node:net";
11
+ import { startProxyServer, CLI_MODELS } from "../src/proxy-server.js";
12
+ import type { BrowserContext } from "playwright";
13
+
14
+ type ClaudeCompleteOptions = { messages: { role: string; content: string }[]; model?: string; timeoutMs?: number };
15
+ type ClaudeCompleteResult = { content: string; model: string; finishReason: string };
16
+
17
+ const stubClaudeComplete = vi.fn(async (
18
+ _ctx: BrowserContext,
19
+ opts: ClaudeCompleteOptions,
20
+ _log: (msg: string) => void
21
+ ): Promise<ClaudeCompleteResult> => ({
22
+ content: `claude mock: ${opts.messages[opts.messages.length - 1]?.content ?? ""}`,
23
+ model: opts.model ?? "web-claude/claude-sonnet",
24
+ finishReason: "stop",
25
+ }));
26
+
27
+ const stubClaudeCompleteStream = vi.fn(async (
28
+ _ctx: BrowserContext,
29
+ opts: ClaudeCompleteOptions,
30
+ onToken: (t: string) => void,
31
+ _log: (msg: string) => void
32
+ ): Promise<ClaudeCompleteResult> => {
33
+ const tokens = ["claude ", "stream ", "mock"];
34
+ for (const t of tokens) onToken(t);
35
+ return { content: tokens.join(""), model: opts.model ?? "web-claude/claude-sonnet", finishReason: "stop" };
36
+ });
37
+
38
+ async function httpPost(url: string, body: unknown): Promise<{ status: number; body: unknown }> {
39
+ return new Promise((resolve, reject) => {
40
+ const data = JSON.stringify(body);
41
+ const u = new URL(url);
42
+ const req = http.request(
43
+ { hostname: u.hostname, port: Number(u.port), path: u.pathname, method: "POST",
44
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) } },
45
+ (res) => { let raw = ""; res.on("data", c => raw += c); res.on("end", () => { try { resolve({ status: res.statusCode ?? 0, body: JSON.parse(raw) }); } catch { resolve({ status: res.statusCode ?? 0, body: raw }); } }); }
46
+ );
47
+ req.on("error", reject); req.write(data); req.end();
48
+ });
49
+ }
50
+ async function httpGet(url: string): Promise<{ status: number; body: unknown }> {
51
+ return new Promise((resolve, reject) => {
52
+ const u = new URL(url);
53
+ const req = http.request({ hostname: u.hostname, port: Number(u.port), path: u.pathname, method: "GET" },
54
+ (res) => { let raw = ""; res.on("data", c => raw += c); res.on("end", () => { try { resolve({ status: res.statusCode ?? 0, body: JSON.parse(raw) }); } catch { resolve({ status: res.statusCode ?? 0, body: raw }); } }); }
55
+ );
56
+ req.on("error", reject); req.end();
57
+ });
58
+ }
59
+
60
+ const fakeCtx = {} as BrowserContext;
61
+ let server: http.Server;
62
+ let baseUrl: string;
63
+
64
+ beforeAll(async () => {
65
+ server = await startProxyServer({
66
+ port: 0, log: () => {}, warn: () => {},
67
+ getClaudeContext: () => fakeCtx,
68
+ // @ts-expect-error — stub types close enough for testing
69
+ _claudeComplete: stubClaudeComplete,
70
+ // @ts-expect-error — stub types close enough for testing
71
+ _claudeCompleteStream: stubClaudeCompleteStream,
72
+ });
73
+ baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
74
+ });
75
+ afterAll(() => server.close());
76
+
77
+ describe("Claude web-session routing — model list", () => {
78
+ it("includes web-claude/* models in /v1/models", async () => {
79
+ const res = await httpGet(`${baseUrl}/v1/models`);
80
+ expect(res.status).toBe(200);
81
+ const ids = (res.body as { data: { id: string }[] }).data.map(m => m.id);
82
+ expect(ids).toContain("web-claude/claude-sonnet");
83
+ expect(ids).toContain("web-claude/claude-opus");
84
+ expect(ids).toContain("web-claude/claude-haiku");
85
+ });
86
+
87
+ it("web-claude/* models listed in CLI_MODELS constant", () => {
88
+ expect(CLI_MODELS.some(m => m.id.startsWith("web-claude/"))).toBe(true);
89
+ });
90
+ });
91
+
92
+ describe("Claude web-session routing — non-streaming", () => {
93
+ it("returns assistant message for web-claude/claude-sonnet", async () => {
94
+ const res = await httpPost(`${baseUrl}/v1/chat/completions`, {
95
+ model: "web-claude/claude-sonnet",
96
+ messages: [{ role: "user", content: "hello claude" }],
97
+ stream: false,
98
+ });
99
+ expect(res.status).toBe(200);
100
+ const body = res.body as { choices: { message: { content: string } }[] };
101
+ expect(body.choices[0].message.content).toContain("claude mock");
102
+ expect(body.choices[0].message.content).toContain("hello claude");
103
+ });
104
+
105
+ it("passes model to stub unchanged", async () => {
106
+ stubClaudeComplete.mockClear();
107
+ await httpPost(`${baseUrl}/v1/chat/completions`, {
108
+ model: "web-claude/claude-opus",
109
+ messages: [{ role: "user", content: "test" }],
110
+ });
111
+ expect(stubClaudeComplete).toHaveBeenCalledOnce();
112
+ expect(stubClaudeComplete.mock.calls[0][1].model).toBe("web-claude/claude-opus");
113
+ });
114
+
115
+ it("returns 503 when no claude context", async () => {
116
+ const s = await startProxyServer({ port: 0, log: () => {}, warn: () => {}, getClaudeContext: () => null });
117
+ const url = `http://127.0.0.1:${(s.address() as AddressInfo).port}`;
118
+ const res = await httpPost(`${url}/v1/chat/completions`, {
119
+ model: "web-claude/claude-sonnet",
120
+ messages: [{ role: "user", content: "hi" }],
121
+ });
122
+ expect(res.status).toBe(503);
123
+ expect((res.body as { error: { code: string } }).error.code).toBe("no_claude_session");
124
+ s.close();
125
+ });
126
+ });
127
+
128
+ describe("Claude web-session routing — streaming", () => {
129
+ it("returns SSE stream", async () => {
130
+ return new Promise<void>((resolve, reject) => {
131
+ const body = JSON.stringify({ model: "web-claude/claude-sonnet", messages: [{ role: "user", content: "stream" }], stream: true });
132
+ const u = new URL(`${baseUrl}/v1/chat/completions`);
133
+ const req = http.request({ hostname: u.hostname, port: Number(u.port), path: u.pathname, method: "POST",
134
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) } },
135
+ (res) => { expect(res.statusCode).toBe(200); let raw = ""; res.on("data", c => raw += c); res.on("end", () => { expect(raw).toContain("[DONE]"); resolve(); }); }
136
+ );
137
+ req.on("error", reject); req.write(body); req.end();
138
+ });
139
+ });
140
+ });