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

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,81 @@
1
+ # Headless Browser Bridge — Roadmap
2
+
3
+ ## Ziel
4
+ Alle Provider (Claude, Gemini, Codex/ChatGPT, Grok) über Playwright Browser-Sessions
5
+ betreiben — keine lokalen CLI-Binaries mehr nötig. Ein headless Chromium, ein Proxy.
6
+
7
+ ## Aktueller Stand (v0.2.28)
8
+ - ✅ Grok: DOM-Polling via grok.com (FERTIG, produktiv)
9
+ - ⏳ Claude: claude CLI binary → Ziel: claude.ai headless
10
+ - ⏳ Gemini: gemini CLI binary → Ziel: gemini.google.com headless
11
+ - ⏳ Codex: codex CLI binary → Ziel: chatgpt.com headless
12
+
13
+ ## Reihenfolge
14
+ 1. **Claude headless** (claude.ai) — höchste Priorität, meistgenutzt
15
+ 2. **Gemini headless** (gemini.google.com)
16
+ 3. **Codex/ChatGPT headless** (chatgpt.com)
17
+
18
+ ## Pro Provider: Was zu bauen ist
19
+ Für jeden Provider brauchen wir:
20
+ 1. `src/<provider>-browser.ts` — DOM-Automation (analog zu grok-client.ts)
21
+ - `sendAndWait(page, message, timeoutMs)` — message senden, auf stable DOM warten
22
+ - `getOrCreatePage(context)` — existierende Page reuse
23
+ 2. Persistent profile dir: `~/.openclaw/<provider>-profile/`
24
+ 3. Cookie-Expiry Tracking (analog zu grok-cookie-expiry.json)
25
+ 4. `/provider-login`, `/provider-status`, `/provider-logout` Commands
26
+ 5. `web-<provider>/*` Modell-Routing im Proxy
27
+ 6. Tests: DOM-Stub via DI-Override (analog zu grok-proxy.test.ts)
28
+
29
+ ## Pro Provider: DOM-Struktur zu ermitteln
30
+ (Muss live gecaptured werden — Browser offen lassen)
31
+
32
+ ### Claude (claude.ai)
33
+ - Login: Google OAuth oder Email
34
+ - Editor selector: TBD (ProseMirror ähnlich wie Grok?)
35
+ - Response selector: TBD
36
+ - Anti-bot: Cloudflare?
37
+
38
+ ### Gemini (gemini.google.com)
39
+ - Login: Google Account (gleicher wie Gemini CLI)
40
+ - Editor selector: TBD
41
+ - Response selector: TBD
42
+ - Anti-bot: Google reCAPTCHA?
43
+
44
+ ### ChatGPT (chatgpt.com)
45
+ - Login: OpenAI Account oder Google/Microsoft OAuth
46
+ - Editor selector: TBD (ProseMirror?)
47
+ - Response selector: TBD
48
+ - Anti-bot: Cloudflare?
49
+
50
+ ## Modell-IDs (nach Implementierung)
51
+ ```
52
+ web-claude/claude-sonnet → claude.ai (Sonnet)
53
+ web-claude/claude-opus → claude.ai (Opus, Pro plan)
54
+ web-gemini/gemini-2-5-pro → gemini.google.com (2.5 Pro)
55
+ web-gemini/gemini-flash → gemini.google.com (Flash)
56
+ web-chatgpt/gpt-4o → chatgpt.com (GPT-4o)
57
+ web-chatgpt/gpt-o3 → chatgpt.com (o3)
58
+ web-grok/grok-3 → grok.com (✅ bereits fertig)
59
+ ```
60
+
61
+ ## Version-Plan
62
+ - v0.2.x — Claude headless (+ Tests)
63
+ - v0.3.x — Gemini headless (+ Tests)
64
+ - v0.4.x — ChatGPT headless (+ Tests)
65
+ - **v1.0.0** — Alle 4 Provider headless, CLI-Dependencies optional,
66
+ vollständige Testabdeckung, CHANGELOG komplett
67
+
68
+ ## Voraussetzungen vor jedem Provider-Release
69
+ - [ ] Alle Tests grün (inkl. neuer Provider-Tests)
70
+ - [ ] DOM-Struktur gecaptured (echte Requests intercepted)
71
+ - [ ] Cookie-Expiry Tracking implementiert
72
+ - [ ] Persistent profile directory dokumentiert
73
+ - [ ] Manuelle End-to-End Test durchgeführt
74
+ - [ ] Alle 3 Plattformen published (GitHub, npm, ClawHub)
75
+
76
+ ## Notizen
77
+ - DOM-Polling interval: 500ms, stable after 3 consecutive identical reads
78
+ - Timeout default: 120s (konfigurierbar via pluginConfig.timeoutMs)
79
+ - Jeder Provider: eigenes Chromium-Profil → Cookies unabhängig
80
+ - Grok-Strategie: grok.com öffnen, ProseMirror füllen, Enter, .message-bubble pollen
81
+ - Cloudflare-Bypass: KEINE direkten fetch()-Calls — immer DOM-Automation
@@ -1,78 +1,54 @@
1
- # STATUS.md — openclaw-cli-bridge-elvatis
2
-
3
- <!-- SECTION: summary -->
4
- v0.2.25 built + tested (51/51). Staged model switching + token refresh stability. Ready to publish.
5
- <!-- /SECTION: summary -->
6
-
7
- <!-- SECTION: version -->
8
- ## Current Version: 0.2.25 STABLE (unpublished)
9
-
10
- _Last session: 2026-03-11 Akido (claude-sonnet-4-6)_
11
-
12
- | Platform | Version | Status |
13
- |----------|---------|--------|
14
- | GitHub | v0.2.25 | ✅ Tagged + Release |
15
- | npm | 0.2.25 | ✅ Published |
16
- | ClawHub | 0.2.25 | Published (direct API clawhub CLI v0.7.0 bug: missing acceptLicenseTerms) |
17
- | Local | 0.2.25 | ✅ Up to date |
18
- <!-- /SECTION: version -->
19
-
20
- <!-- SECTION: build_health -->
21
- ## Build Health
22
-
23
- | Check | Result | Notes |
24
- |-------|--------|-------|
25
- | `npm run build` | ✅ | TypeScript compiles clean, no errors |
26
- | `npm test` | ✅ 51/51 | All tests pass |
27
- | `npm run typecheck` | | Implied by build |
28
- | Plugin loads in gateway | | Verified at v0.2.21; no structural changes |
29
- <!-- /SECTION: build_health -->
30
-
31
- <!-- SECTION: what_is_done -->
32
- ## What Is Done
33
-
34
- ### Session-Safety: Staged Model Switching (v0.2.25)
35
- - **`/cli-*` stages by default** — switch saved to `~/.openclaw/cli-bridge-pending.json`, NOT applied. Shows warning + instructions.
36
- - **`/cli-* --now`** immediate switch (user's explicit choice; only use between sessions)
37
- - **`/cli-apply`** apply staged switch after finishing current task
38
- - **`/cli-pending`** show staged switch state
39
- - ✅ **`/cli-back`** — restore previous model + clear any staged switch
40
- - **`/cli-list`** — updated to show pending state + switching instructions
41
-
42
- ### Token Refresh Stability (v0.2.25merged from v0.2.24)
43
- - ✅ Sleep-resilient: `setInterval(10min)` polling instead of long `setTimeout`
44
- - ✅ No timer-leak: `stopTokenRefresh()` called at top of `scheduleTokenRefresh()`
45
- - `stopTokenRefresh()` exported; called via `server.on("close")`
46
-
47
- ### Previously Validated (v0.2.23 and below)
48
- - ✅ Phase 1: `openai-codex` provider via `~/.codex/auth.json`
49
- - Phase 2: Local proxy on `127.0.0.1:31337` (Gemini + Claude CLI)
50
- - Phase 3: 15 slash commands (all `/cli-*`)
51
- - Model allowlist, vllm prefix stripping, buildMinimalEnv XDG vars
52
- - End-to-end tested: claude-sonnet-4-6 claude-haiku-4-5 ✅ gemini-2.5-flash ✅ gemini-2.5-pro ✅ codex ✅
53
- <!-- /SECTION: what_is_done -->
54
-
55
- <!-- SECTION: what_is_missing -->
56
- ## What Is Missing / Open
57
-
58
- - ✅ **v0.2.25 published** — GitHub, npm, ClawHub alle auf 0.2.25
59
- - ℹ️ **Claude CLI auth expires ~90 days** — when `/cli-test` returns 401, run `claude auth login`
60
- - ℹ️ **Config patcher writes `openclaw.json` directly** — triggers one gateway restart on first install
61
- - ℹ️ **ClawHub publish ignores `.clawhubignore`** — use rsync workaround (see CONVENTIONS.md)
62
- <!-- /SECTION: what_is_missing -->
63
-
64
- <!-- SECTION: bugs_fixed -->
65
- ## Bug History
66
-
67
- | Version | Bug | Fix |
68
- |---------|-----|-----|
69
- | 0.2.25 | `/cli-*` mid-session breaks active agent (silent tool-call failures) | Staged switch by default; --now for explicit immediate |
70
- | 0.2.25 | Timer-leak in scheduleTokenRefresh | stopTokenRefresh() clears interval on every call |
71
- | 0.2.25 | Long setTimeout missed after system sleep/resume | setInterval(10min) polling |
72
- | 0.2.25 | Token refresh interval leaked on proxy close | server.on("close", stopTokenRefresh) |
73
- | 0.2.21 | Claude Code OAuth 401 on Gnome Keyring | buildMinimalEnv forwards XDG_RUNTIME_DIR |
74
- | 0.2.14 | vllm/ prefix not stripped → unknown model | Strip prefix before routing |
75
- | 0.2.13 | requireAuth:true blocked webchat commands | requireAuth:false |
76
- | 0.2.9 | fuser -k SIGKILL'd gateway process | Safe health probe |
77
- | 0.2.7–8 | EADDRINUSE on hot-reload | closeAllConnections() + registerService |
78
- <!-- /SECTION: bugs_fixed -->
1
+ # STATUS — openclaw-cli-bridge-elvatis
2
+
3
+ ## Current Version: 0.2.28 (npm + ClawHub + GitHub)
4
+
5
+ ## What's Done
6
+ - v0.2.25: Sleep-resilient token refresh (setInterval), staged /cli-* switch
7
+ - v0.2.26: Grok DOM-polling bridge (grok-client.ts, grok-session.ts)
8
+ - v0.2.27: Persistent Chromium profile (~/.openclaw/grok-profile/)
9
+ - v0.2.28: Cookie-expiry tracking (/grok-status shows ✅/⚠️/🚨)
10
+ - claude-browser.ts: DOM-automation for claude.ai (not yet in proxy — NEXT)
11
+ - 77/77 tests green
12
+
13
+ ## Next: v0.3.x → v1.0.0 — Full Headless Provider Bridge
14
+
15
+ ### Provider Status
16
+ | Provider | DOM confirmed | browser.ts | Proxy routed | Login cmd | Tests |
17
+ |---|---|---|---|---|---|
18
+ | Grok | ✅ | ✅ grok-client.ts | ✅ web-grok/* | ✅ /grok-login | ✅ |
19
+ | Claude | ✅ | ✅ claude-browser.ts | ❌ | ❌ | partial |
20
+ | Gemini | ❌ | ❌ | ❌ | ❌ | ❌ |
21
+ | ChatGPT | ❌ | ❌ | ❌ | ❌ | ❌ |
22
+
23
+ ### Claude DOM (confirmed 2026-03-11)
24
+ - URL: https://claude.ai/new
25
+ - Editor: .ProseMirror (tiptap)
26
+ - Messages: [data-test-render-count] divs
27
+ - Assistant msgs: child div class "group" (no "mb-1 mt-6")
28
+ - User msgs: child div class "mb-1 mt-6 group"
29
+ - CLOUDFLARE: persistent headless blocked — must use OpenClaw browser (CDP 18800)
30
+ - Tested working: CLAUDE_WORKS response confirmed via OpenClaw browser
31
+
32
+ ### Next Steps (in order)
33
+ 1. Add connectToOpenClawBrowser() to claude-browser.ts (same as grok-session.ts)
34
+ 2. Add web-claude/* routing to proxy-server.ts (same as web-grok/*)
35
+ 3. Add /claude-login, /claude-status, /claude-logout to index.ts
36
+ 4. Add claude-browser integration tests (DI-override, same as grok-proxy.test.ts)
37
+ 5. Repeat for Gemini (gemini.google.com) and ChatGPT (chatgpt.com)
38
+ 6. Bump to v1.0.0 when all 4 providers green + all tests pass
39
+
40
+ ## Key Files
41
+ - src/claude-browser.ts — Claude DOM automation (ready, not wired)
42
+ - src/grok-client.tsreference implementation
43
+ - src/grok-session.ts reference for login/session management
44
+ - src/proxy-server.ts add web-claude/* routing here
45
+ - index.ts add /claude-login here
46
+ - test/claude-browser.test.ts — unit tests (partial, needs proxy integration test)
47
+
48
+ ## Constraints
49
+ - OpenClaw browser (CDP 18800) required for Cloudflare bypass
50
+ - persistent profile approach fails (fingerprint mismatch)
51
+ - Each provider: own profile dir ~/.openclaw/<provider>-profile/
52
+ - All providers share same proxy port 31337
53
+ - Publish only after full test pass (77+ tests green)
54
+ - All 3 platforms on every release: GitHub + npm + ClawHub
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > OpenClaw plugin that bridges locally installed AI CLIs (Codex, Gemini, Claude Code) as model providers — with slash commands for instant model switching, restore, health testing, and model listing.
4
4
 
5
- **Current version:** `0.2.28`
5
+ **Current version:** `0.2.29`
6
6
 
7
7
  ---
8
8
 
@@ -287,6 +287,13 @@ npm test # vitest run (45 tests)
287
287
 
288
288
  ## Changelog
289
289
 
290
+ ### v0.2.29
291
+ - **feat:** `claude-browser.ts` — claude.ai DOM-automation (ProseMirror + `[data-test-render-count]` polling)
292
+ - **feat:** `web-claude/*` models in proxy (web-claude/claude-sonnet, claude-opus, claude-haiku)
293
+ - **feat:** `/claude-login`, `/claude-status`, `/claude-logout` commands
294
+ - **feat:** Claude cookie-expiry tracking (`~/.openclaw/claude-cookie-expiry.json`)
295
+ - **test:** 84/84 tests green (+7 claude-proxy tests, +8 claude-browser unit tests)
296
+
290
297
  ### v0.2.28
291
298
  - **feat:** `/grok-login` scans auth cookie expiry (sso cookie) and saves to `~/.openclaw/grok-cookie-expiry.json`
292
299
  - **feat:** `/grok-status` shows cookie expiry with color-coded warnings (🚨 <7d, ⚠️ <14d, ✅ otherwise)
package/SKILL.md CHANGED
@@ -53,4 +53,4 @@ Each command runs `openclaw models set <model>` atomically and replies with a co
53
53
 
54
54
  See `README.md` for full configuration reference and architecture diagram.
55
55
 
56
- **Version:** 0.2.28
56
+ **Version:** 0.2.29
package/index.ts CHANGED
@@ -89,6 +89,42 @@ let grokContext: BrowserContext | null = null;
89
89
  // Persistent profile dir — survives gateway restarts, keeps cookies intact
90
90
  const GROK_PROFILE_DIR = join(homedir(), ".openclaw", "grok-profile");
91
91
 
92
+ // ── Claude web-session state ──────────────────────────────────────────────────
93
+ let claudeContext: BrowserContext | null = null;
94
+ const CLAUDE_EXPIRY_FILE = join(homedir(), ".openclaw", "claude-cookie-expiry.json");
95
+
96
+ interface ClaudeExpiryInfo {
97
+ expiresAt: number;
98
+ loginAt: number;
99
+ cookieName: string;
100
+ }
101
+
102
+ function saveClaudeExpiry(info: ClaudeExpiryInfo): void {
103
+ try { writeFileSync(CLAUDE_EXPIRY_FILE, JSON.stringify(info, null, 2)); } catch { /* ignore */ }
104
+ }
105
+ function loadClaudeExpiry(): ClaudeExpiryInfo | null {
106
+ try { return JSON.parse(readFileSync(CLAUDE_EXPIRY_FILE, "utf-8")) as ClaudeExpiryInfo; } catch { return null; }
107
+ }
108
+ function formatClaudeExpiry(info: ClaudeExpiryInfo): string {
109
+ const daysLeft = Math.floor((info.expiresAt - Date.now()) / 86_400_000);
110
+ const dateStr = new Date(info.expiresAt).toISOString().substring(0, 10);
111
+ if (daysLeft < 0) return `⚠️ EXPIRED (${dateStr}) — run /claude-login`;
112
+ if (daysLeft <= 7) return `🚨 expires in ${daysLeft}d (${dateStr}) — run /claude-login NOW`;
113
+ if (daysLeft <= 14) return `⚠️ expires in ${daysLeft}d (${dateStr}) — run /claude-login soon`;
114
+ return `✅ valid for ${daysLeft} more days (expires ${dateStr})`;
115
+ }
116
+ async function scanClaudeCookieExpiry(ctx: BrowserContext): Promise<ClaudeExpiryInfo | null> {
117
+ try {
118
+ const cookies = await ctx.cookies(["https://claude.ai", "https://anthropic.com"]);
119
+ const authCookies = cookies.filter(c => ["sessionKey", "intercom-session-igviqkfk"].includes(c.name) && c.expires && c.expires > 0);
120
+ if (!authCookies.length) return null;
121
+ authCookies.sort((a, b) => (a.expires ?? 0) - (b.expires ?? 0));
122
+ const earliest = authCookies[0];
123
+ return { expiresAt: (earliest.expires ?? 0) * 1000, loginAt: Date.now(), cookieName: earliest.name };
124
+ } catch { return null; }
125
+ }
126
+ // ─────────────────────────────────────────────────────────────────────────────
127
+
92
128
  // Cookie expiry tracking file — written on /grok-login, read on startup
93
129
  const GROK_EXPIRY_FILE = join(homedir(), ".openclaw", "grok-cookie-expiry.json");
94
130
 
@@ -547,7 +583,7 @@ function proxyTestRequest(
547
583
  const plugin = {
548
584
  id: "openclaw-cli-bridge-elvatis",
549
585
  name: "OpenClaw CLI Bridge",
550
- version: "0.2.28",
586
+ version: "0.2.29",
551
587
  description:
552
588
  "Phase 1: openai-codex auth bridge. " +
553
589
  "Phase 2: HTTP proxy for gemini/claude CLIs. " +
@@ -670,6 +706,17 @@ const plugin = {
670
706
  }
671
707
  return null;
672
708
  },
709
+ getClaudeContext: () => claudeContext,
710
+ connectClaudeContext: async () => {
711
+ const ctx = await connectToOpenClawBrowser((msg) => api.logger.info(msg));
712
+ if (ctx) {
713
+ const { getOrCreateClaudePage } = await import("./src/claude-browser.js");
714
+ const { page } = await getOrCreateClaudePage(ctx);
715
+ const editor = await page.locator(".ProseMirror").isVisible().catch(() => false);
716
+ if (editor) { claudeContext = ctx; return ctx; }
717
+ }
718
+ return null;
719
+ },
673
720
  });
674
721
  proxyServer = server;
675
722
  api.logger.info(
@@ -702,6 +749,17 @@ const plugin = {
702
749
  }
703
750
  return null;
704
751
  },
752
+ getClaudeContext: () => claudeContext,
753
+ connectClaudeContext: async () => {
754
+ const ctx = await connectToOpenClawBrowser((msg) => api.logger.info(msg));
755
+ if (ctx) {
756
+ const { getOrCreateClaudePage } = await import("./src/claude-browser.js");
757
+ const { page } = await getOrCreateClaudePage(ctx);
758
+ const editor = await page.locator(".ProseMirror").isVisible().catch(() => false);
759
+ if (editor) { claudeContext = ctx; return ctx; }
760
+ }
761
+ return null;
762
+ },
705
763
  });
706
764
  proxyServer = server;
707
765
  api.logger.info(`[cli-bridge] proxy ready on :${port} (retry)`);
@@ -1033,13 +1091,109 @@ const plugin = {
1033
1091
  name: "grok-logout",
1034
1092
  description: "Disconnect from grok.com session (does not close the browser)",
1035
1093
  handler: async (): Promise<PluginCommandResult> => {
1036
- // Don't close the context — it belongs to the OpenClaw browser, not us
1037
1094
  grokContext = null;
1038
1095
  deleteSession(grokSessionPath);
1039
1096
  return { text: "✅ Disconnected from grok.com. Run `/grok-login` to reconnect." };
1040
1097
  },
1041
1098
  } satisfies OpenClawPluginCommandDefinition);
1042
1099
 
1100
+ // ── Claude web-session commands ───────────────────────────────────────────
1101
+ api.registerCommand({
1102
+ name: "claude-login",
1103
+ description: "Authenticate claude.ai: imports session from OpenClaw browser",
1104
+ handler: async (): Promise<PluginCommandResult> => {
1105
+ if (claudeContext) {
1106
+ const { getOrCreateClaudePage } = await import("./src/claude-browser.js");
1107
+ try {
1108
+ const { page } = await getOrCreateClaudePage(claudeContext);
1109
+ const editor = await page.locator(".ProseMirror").isVisible().catch(() => false);
1110
+ if (editor) return { text: "✅ Already connected to claude.ai. Use `/claude-logout` first to reset." };
1111
+ } catch { /* fall through */ }
1112
+ claudeContext = null;
1113
+ }
1114
+
1115
+ api.logger.info("[cli-bridge:claude] /claude-login: connecting to OpenClaw browser…");
1116
+ const { chromium } = await import("playwright");
1117
+
1118
+ // Import cookies from OpenClaw browser
1119
+ let importedCookies: unknown[] = [];
1120
+ try {
1121
+ const ocBrowser = await chromium.connectOverCDP("http://127.0.0.1:18800", { timeout: 3000 });
1122
+ const ocCtx = ocBrowser.contexts()[0];
1123
+ if (ocCtx) {
1124
+ importedCookies = await ocCtx.cookies(["https://claude.ai", "https://anthropic.com"]);
1125
+ api.logger.info(`[cli-bridge:claude] imported ${importedCookies.length} cookies`);
1126
+ }
1127
+ await ocBrowser.close().catch(() => {});
1128
+ } catch {
1129
+ api.logger.info("[cli-bridge:claude] OpenClaw browser not available");
1130
+ }
1131
+
1132
+ // Connect to OpenClaw browser context for session
1133
+ const ctx = await connectToOpenClawBrowser((msg) => api.logger.info(msg));
1134
+ if (!ctx) return { text: "❌ Could not connect to OpenClaw browser.\nMake sure claude.ai is open in your browser." };
1135
+
1136
+ // Navigate to claude.ai/new if not already there
1137
+ const { getOrCreateClaudePage } = await import("./src/claude-browser.js");
1138
+ let page;
1139
+ try {
1140
+ ({ page } = await getOrCreateClaudePage(ctx));
1141
+ } catch (err) {
1142
+ return { text: `❌ Failed to open claude.ai: ${(err as Error).message}` };
1143
+ }
1144
+
1145
+ // Verify editor is visible
1146
+ const editor = await page.locator(".ProseMirror").isVisible().catch(() => false);
1147
+ if (!editor) {
1148
+ return { text: "❌ claude.ai editor not visible — are you logged in?\nOpen claude.ai in your browser and try again." };
1149
+ }
1150
+
1151
+ claudeContext = ctx;
1152
+
1153
+ // Scan cookie expiry
1154
+ const expiry = await scanClaudeCookieExpiry(ctx);
1155
+ if (expiry) {
1156
+ saveClaudeExpiry(expiry);
1157
+ api.logger.info(`[cli-bridge:claude] cookie expiry: ${new Date(expiry.expiresAt).toISOString()}`);
1158
+ }
1159
+ const expiryLine = expiry ? `\n\n🕐 Cookie expiry: ${formatClaudeExpiry(expiry)}` : "";
1160
+
1161
+ 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}` };
1162
+ },
1163
+ } satisfies OpenClawPluginCommandDefinition);
1164
+
1165
+ api.registerCommand({
1166
+ name: "claude-status",
1167
+ description: "Check claude.ai session status",
1168
+ handler: async (): Promise<PluginCommandResult> => {
1169
+ if (!claudeContext) {
1170
+ return { text: "❌ No active claude.ai session\nRun `/claude-login` to authenticate." };
1171
+ }
1172
+ const { getOrCreateClaudePage } = await import("./src/claude-browser.js");
1173
+ try {
1174
+ const { page } = await getOrCreateClaudePage(claudeContext);
1175
+ const editor = await page.locator(".ProseMirror").isVisible().catch(() => false);
1176
+ if (editor) {
1177
+ const expiry = loadClaudeExpiry();
1178
+ const expiryLine = expiry ? `\n🕐 ${formatClaudeExpiry(expiry)}` : "";
1179
+ return { text: `✅ claude.ai session active\nProxy: \`127.0.0.1:${port}\`\nModels: web-claude/claude-sonnet, web-claude/claude-opus, web-claude/claude-haiku${expiryLine}` };
1180
+ }
1181
+ } catch { /* fall through */ }
1182
+ claudeContext = null;
1183
+ return { text: "❌ Session lost — run `/claude-login` to re-authenticate." };
1184
+ },
1185
+ } satisfies OpenClawPluginCommandDefinition);
1186
+
1187
+ api.registerCommand({
1188
+ name: "claude-logout",
1189
+ description: "Disconnect from claude.ai session",
1190
+ handler: async (): Promise<PluginCommandResult> => {
1191
+ claudeContext = null;
1192
+ return { text: "✅ Disconnected from claude.ai. Run `/claude-login` to reconnect." };
1193
+ },
1194
+ } satisfies OpenClawPluginCommandDefinition);
1195
+ // ─────────────────────────────────────────────────────────────────────────
1196
+
1043
1197
  const allCommands = [
1044
1198
  ...CLI_MODEL_COMMANDS.map((c) => `/${c.name}`),
1045
1199
  "/cli-back",
@@ -1048,6 +1202,9 @@ const plugin = {
1048
1202
  "/grok-login",
1049
1203
  "/grok-status",
1050
1204
  "/grok-logout",
1205
+ "/claude-login",
1206
+ "/claude-status",
1207
+ "/claude-logout",
1051
1208
  ];
1052
1209
  api.logger.info(`[cli-bridge] registered ${allCommands.length} commands: ${allCommands.join(", ")}`);
1053
1210
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-cli-bridge-elvatis",
3
3
  "name": "OpenClaw CLI Bridge",
4
- "version": "0.2.28",
4
+ "version": "0.2.29",
5
5
  "description": "Phase 1: openai-codex auth bridge. Phase 2: local HTTP proxy routing model calls through gemini/claude CLIs (vllm provider).",
6
6
  "providers": [
7
7
  "openai-codex"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elvatis_com/openclaw-cli-bridge-elvatis",
3
- "version": "0.2.28",
3
+ "version": "0.2.29",
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,233 @@
1
+ /**
2
+ * claude-browser.ts
3
+ *
4
+ * Claude.ai browser automation via Playwright DOM-polling.
5
+ * Identical strategy to grok-client.ts — no direct API calls,
6
+ * everything runs through the authenticated browser page.
7
+ *
8
+ * DOM structure (as of 2026-03-11):
9
+ * Editor: .ProseMirror (tiptap)
10
+ * Messages: [data-test-render-count] divs, alternating user/assistant
11
+ * Assistant messages: child div with class "group" (no "mb-1 mt-6")
12
+ */
13
+
14
+ import type { BrowserContext, Page } from "playwright";
15
+
16
+ export interface ChatMessage {
17
+ role: "system" | "user" | "assistant";
18
+ content: string;
19
+ }
20
+
21
+ export interface ClaudeBrowserOptions {
22
+ messages: ChatMessage[];
23
+ model?: string;
24
+ timeoutMs?: number;
25
+ }
26
+
27
+ export interface ClaudeBrowserResult {
28
+ content: string;
29
+ model: string;
30
+ finishReason: string;
31
+ }
32
+
33
+ const DEFAULT_TIMEOUT_MS = 120_000;
34
+ const STABLE_CHECKS = 3;
35
+ const STABLE_INTERVAL_MS = 500;
36
+ const CLAUDE_HOME = "https://claude.ai/new";
37
+
38
+ const MODEL_MAP: Record<string, string> = {
39
+ "claude-sonnet": "claude-sonnet",
40
+ "claude-opus": "claude-opus",
41
+ "claude-haiku": "claude-haiku",
42
+ "claude-sonnet-4-5": "claude-sonnet-4-5",
43
+ "claude-sonnet-4-6": "claude-sonnet-4-6",
44
+ "claude-opus-4-5": "claude-opus-4-5",
45
+ };
46
+
47
+ function resolveModel(m?: string): string {
48
+ const clean = (m ?? "claude-sonnet").replace("web-claude/", "");
49
+ return MODEL_MAP[clean] ?? "claude-sonnet";
50
+ }
51
+
52
+ function flattenMessages(messages: ChatMessage[]): string {
53
+ if (messages.length === 1) return messages[0].content;
54
+ return messages
55
+ .map((m) => {
56
+ if (m.role === "system") return `[System]: ${m.content}`;
57
+ if (m.role === "assistant") return `[Assistant]: ${m.content}`;
58
+ return m.content;
59
+ })
60
+ .join("\n\n");
61
+ }
62
+
63
+ /**
64
+ * Get or create a claude.ai/new page in the given context.
65
+ */
66
+ export async function getOrCreateClaudePage(
67
+ context: BrowserContext
68
+ ): Promise<{ page: Page; owned: boolean }> {
69
+ const existing = context.pages().filter((p) => p.url().startsWith("https://claude.ai"));
70
+ if (existing.length > 0) return { page: existing[0], owned: false };
71
+ const page = await context.newPage();
72
+ await page.goto(CLAUDE_HOME, { waitUntil: "domcontentloaded", timeout: 15_000 });
73
+ await new Promise((r) => setTimeout(r, 2_000));
74
+ return { page, owned: true };
75
+ }
76
+
77
+ /**
78
+ * Count assistant message containers currently on the page.
79
+ * Assistant messages: [data-test-render-count] where child div lacks "mb-1 mt-6".
80
+ */
81
+ async function countAssistantMessages(page: Page): Promise<number> {
82
+ return page.evaluate(() => {
83
+ const all = [...document.querySelectorAll("[data-test-render-count]")];
84
+ return all.filter((el) => {
85
+ const child = el.querySelector("div");
86
+ return child && !child.className.includes("mb-1");
87
+ }).length;
88
+ });
89
+ }
90
+
91
+ /**
92
+ * Get the text of the last assistant message.
93
+ */
94
+ async function getLastAssistantText(page: Page): Promise<string> {
95
+ return page.evaluate(() => {
96
+ const all = [...document.querySelectorAll("[data-test-render-count]")];
97
+ const assistants = all.filter((el) => {
98
+ const child = el.querySelector("div");
99
+ return child && !child.className.includes("mb-1");
100
+ });
101
+ return assistants[assistants.length - 1]?.textContent?.trim() ?? "";
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Send a message and wait for a stable response via DOM-polling.
107
+ */
108
+ async function sendAndWait(
109
+ page: Page,
110
+ message: string,
111
+ timeoutMs: number,
112
+ log: (msg: string) => void
113
+ ): Promise<string> {
114
+ const countBefore = await countAssistantMessages(page);
115
+
116
+ // Type into ProseMirror editor
117
+ await page.evaluate((msg: string) => {
118
+ const ed = document.querySelector(".ProseMirror") as HTMLElement | null;
119
+ if (!ed) throw new Error("Claude editor not found");
120
+ ed.focus();
121
+ document.execCommand("insertText", false, msg);
122
+ }, message);
123
+
124
+ await new Promise((r) => setTimeout(r, 300));
125
+ await page.keyboard.press("Enter");
126
+
127
+ log(`claude-browser: message sent (${message.length} chars), waiting…`);
128
+
129
+ const deadline = Date.now() + timeoutMs;
130
+ let lastText = "";
131
+ let stableCount = 0;
132
+
133
+ while (Date.now() < deadline) {
134
+ await new Promise((r) => setTimeout(r, STABLE_INTERVAL_MS));
135
+
136
+ const currentCount = await countAssistantMessages(page);
137
+ if (currentCount <= countBefore) continue; // response not started yet
138
+
139
+ const text = await getLastAssistantText(page);
140
+
141
+ if (text && text === lastText) {
142
+ stableCount++;
143
+ if (stableCount >= STABLE_CHECKS) {
144
+ log(`claude-browser: response stable (${text.length} chars)`);
145
+ return text;
146
+ }
147
+ } else {
148
+ stableCount = 0;
149
+ lastText = text;
150
+ }
151
+ }
152
+
153
+ throw new Error(`claude.ai response timeout after ${timeoutMs}ms`);
154
+ }
155
+
156
+ // ─────────────────────────────────────────────────────────────────────────────
157
+
158
+ export async function claudeComplete(
159
+ context: BrowserContext,
160
+ opts: ClaudeBrowserOptions,
161
+ log: (msg: string) => void
162
+ ): Promise<ClaudeBrowserResult> {
163
+ const { page, owned } = await getOrCreateClaudePage(context);
164
+ const model = resolveModel(opts.model);
165
+ const prompt = flattenMessages(opts.messages);
166
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
167
+
168
+ log(`claude-browser: complete model=${model}`);
169
+
170
+ try {
171
+ const content = await sendAndWait(page, prompt, timeoutMs, log);
172
+ return { content, model, finishReason: "stop" };
173
+ } finally {
174
+ if (owned) await page.close().catch(() => {});
175
+ }
176
+ }
177
+
178
+ export async function claudeCompleteStream(
179
+ context: BrowserContext,
180
+ opts: ClaudeBrowserOptions,
181
+ onToken: (token: string) => void,
182
+ log: (msg: string) => void
183
+ ): Promise<ClaudeBrowserResult> {
184
+ const { page, owned } = await getOrCreateClaudePage(context);
185
+ const model = resolveModel(opts.model);
186
+ const prompt = flattenMessages(opts.messages);
187
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
188
+
189
+ log(`claude-browser: stream model=${model}`);
190
+
191
+ const countBefore = await countAssistantMessages(page);
192
+
193
+ await page.evaluate((msg: string) => {
194
+ const ed = document.querySelector(".ProseMirror") as HTMLElement | null;
195
+ if (!ed) throw new Error("Claude editor not found");
196
+ ed.focus();
197
+ document.execCommand("insertText", false, msg);
198
+ }, prompt);
199
+ await new Promise((r) => setTimeout(r, 300));
200
+ await page.keyboard.press("Enter");
201
+
202
+ const deadline = Date.now() + timeoutMs;
203
+ let emittedLength = 0;
204
+ let lastText = "";
205
+ let stableCount = 0;
206
+
207
+ while (Date.now() < deadline) {
208
+ await new Promise((r) => setTimeout(r, STABLE_INTERVAL_MS));
209
+
210
+ const currentCount = await countAssistantMessages(page);
211
+ if (currentCount <= countBefore) continue;
212
+
213
+ const text = await getLastAssistantText(page);
214
+
215
+ if (text.length > emittedLength) {
216
+ onToken(text.slice(emittedLength));
217
+ emittedLength = text.length;
218
+ }
219
+
220
+ if (text && text === lastText) {
221
+ stableCount++;
222
+ if (stableCount >= STABLE_CHECKS) {
223
+ log(`claude-browser: stream done (${text.length} chars)`);
224
+ return { content: text, model, finishReason: "stop" };
225
+ }
226
+ } else {
227
+ stableCount = 0;
228
+ lastText = text;
229
+ }
230
+ }
231
+
232
+ throw new Error(`claude.ai stream timeout after ${timeoutMs}ms`);
233
+ }
@@ -13,6 +13,7 @@ import { randomBytes } from "node:crypto";
13
13
  import { type ChatMessage, routeToCliRunner } from "./cli-runner.js";
14
14
  import { scheduleTokenRefresh, setAuthLogger, stopTokenRefresh } from "./claude-auth.js";
15
15
  import { grokComplete, grokCompleteStream, type ChatMessage as GrokChatMessage } from "./grok-client.js";
16
+ import { claudeComplete, claudeCompleteStream, type ChatMessage as ClaudeBrowserChatMessage } from "./claude-browser.js";
16
17
  import type { BrowserContext } from "playwright";
17
18
 
18
19
  export type GrokCompleteOptions = Parameters<typeof grokComplete>[1];
@@ -33,6 +34,14 @@ export interface ProxyServerOptions {
33
34
  _grokComplete?: typeof grokComplete;
34
35
  /** Override for testing — replaces grokCompleteStream */
35
36
  _grokCompleteStream?: typeof grokCompleteStream;
37
+ /** Returns the current authenticated Claude BrowserContext (null if not logged in) */
38
+ getClaudeContext?: () => BrowserContext | null;
39
+ /** Async lazy connect — called when getClaudeContext returns null */
40
+ connectClaudeContext?: () => Promise<BrowserContext | null>;
41
+ /** Override for testing — replaces claudeComplete */
42
+ _claudeComplete?: typeof claudeComplete;
43
+ /** Override for testing — replaces claudeCompleteStream */
44
+ _claudeCompleteStream?: typeof claudeCompleteStream;
36
45
  }
37
46
 
38
47
  /** Available CLI bridge models for GET /v1/models */
@@ -78,6 +87,10 @@ export const CLI_MODELS = [
78
87
  { id: "web-grok/grok-3-fast", name: "Grok 3 Fast (web session)", contextWindow: 131_072, maxTokens: 131_072 },
79
88
  { id: "web-grok/grok-3-mini", name: "Grok 3 Mini (web session)", contextWindow: 131_072, maxTokens: 131_072 },
80
89
  { id: "web-grok/grok-3-mini-fast", name: "Grok 3 Mini Fast (web session)", contextWindow: 131_072, maxTokens: 131_072 },
90
+ // Claude web-session models (requires /claude-login)
91
+ { id: "web-claude/claude-sonnet", name: "Claude Sonnet (web session)", contextWindow: 200_000, maxTokens: 8192 },
92
+ { id: "web-claude/claude-opus", name: "Claude Opus (web session)", contextWindow: 200_000, maxTokens: 8192 },
93
+ { id: "web-claude/claude-haiku", name: "Claude Haiku (web session)", contextWindow: 200_000, maxTokens: 8192 },
81
94
  ];
82
95
 
83
96
  // ──────────────────────────────────────────────────────────────────────────────
@@ -249,6 +262,55 @@ async function handleRequest(
249
262
  }
250
263
  // ─────────────────────────────────────────────────────────────────────────
251
264
 
265
+ // ── Claude web-session routing ────────────────────────────────────────────
266
+ if (model.startsWith("web-claude/")) {
267
+ let claudeCtx = opts.getClaudeContext?.() ?? null;
268
+ if (!claudeCtx && opts.connectClaudeContext) {
269
+ claudeCtx = await opts.connectClaudeContext();
270
+ }
271
+ if (!claudeCtx) {
272
+ res.writeHead(503, { "Content-Type": "application/json" });
273
+ res.end(JSON.stringify({ error: { message: "No active claude.ai session. Use /claude-login to authenticate.", code: "no_claude_session" } }));
274
+ return;
275
+ }
276
+ const timeoutMs = opts.timeoutMs ?? 120_000;
277
+ const claudeMessages = messages as ClaudeBrowserChatMessage[];
278
+ const doClaudeComplete = opts._claudeComplete ?? claudeComplete;
279
+ const doClaudeCompleteStream = opts._claudeCompleteStream ?? claudeCompleteStream;
280
+ try {
281
+ if (stream) {
282
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", ...corsHeaders() });
283
+ sendSseChunk(res, { id, created, model, delta: { role: "assistant" }, finish_reason: null });
284
+ const result = await doClaudeCompleteStream(
285
+ claudeCtx,
286
+ { messages: claudeMessages, model, timeoutMs },
287
+ (token) => sendSseChunk(res, { id, created, model, delta: { content: token }, finish_reason: null }),
288
+ opts.log
289
+ );
290
+ sendSseChunk(res, { id, created, model, delta: {}, finish_reason: result.finishReason });
291
+ res.write("data: [DONE]\n\n");
292
+ res.end();
293
+ } else {
294
+ const result = await doClaudeComplete(claudeCtx, { messages: claudeMessages, model, timeoutMs }, opts.log);
295
+ res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
296
+ res.end(JSON.stringify({
297
+ id, object: "chat.completion", created, model,
298
+ choices: [{ index: 0, message: { role: "assistant", content: result.content }, finish_reason: result.finishReason }],
299
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
300
+ }));
301
+ }
302
+ } catch (err) {
303
+ const msg = (err as Error).message;
304
+ opts.warn(`[cli-bridge] Claude browser error for ${model}: ${msg}`);
305
+ if (!res.headersSent) {
306
+ res.writeHead(500, { "Content-Type": "application/json" });
307
+ res.end(JSON.stringify({ error: { message: msg, type: "claude_browser_error" } }));
308
+ }
309
+ }
310
+ return;
311
+ }
312
+ // ─────────────────────────────────────────────────────────────────────────
313
+
252
314
  // ── CLI runner routing (Gemini / Claude Code) ─────────────────────────────
253
315
  let content: string;
254
316
  try {
@@ -0,0 +1,93 @@
1
+ /**
2
+ * test/claude-browser.test.ts
3
+ *
4
+ * Unit tests for claude-browser.ts helper functions.
5
+ * Does not require a real browser — tests the pure logic.
6
+ */
7
+
8
+ import { describe, it, expect } from "vitest";
9
+
10
+ // ─── Test the flatten + model resolution logic directly ──────────────────────
11
+ // We import internal helpers by re-exporting them from a test shim, or we just
12
+ // test the public surface via duck-typed mocks.
13
+
14
+ describe("claude-browser — model resolution", () => {
15
+ // We can test via the exported functions indirectly through proxy tests.
16
+ // Direct unit tests use small isolated helpers copied here.
17
+
18
+ function resolveModel(m?: string): string {
19
+ const clean = (m ?? "claude-sonnet").replace("web-claude/", "");
20
+ const allowed = ["claude-sonnet","claude-opus","claude-haiku","claude-sonnet-4-5","claude-sonnet-4-6","claude-opus-4-5"];
21
+ return allowed.includes(clean) ? clean : "claude-sonnet";
22
+ }
23
+
24
+ it("strips web-claude/ prefix", () => {
25
+ expect(resolveModel("web-claude/claude-sonnet")).toBe("claude-sonnet");
26
+ expect(resolveModel("web-claude/claude-opus")).toBe("claude-opus");
27
+ });
28
+
29
+ it("falls back to claude-sonnet for unknown models", () => {
30
+ expect(resolveModel("web-claude/unknown-model")).toBe("claude-sonnet");
31
+ expect(resolveModel(undefined)).toBe("claude-sonnet");
32
+ });
33
+
34
+ it("accepts known models without prefix", () => {
35
+ expect(resolveModel("claude-sonnet-4-6")).toBe("claude-sonnet-4-6");
36
+ expect(resolveModel("claude-opus")).toBe("claude-opus");
37
+ });
38
+ });
39
+
40
+ describe("claude-browser — message flattening", () => {
41
+ function flattenMessages(messages: { role: string; content: string }[]): string {
42
+ if (messages.length === 1) return messages[0].content;
43
+ return messages.map((m) => {
44
+ if (m.role === "system") return `[System]: ${m.content}`;
45
+ if (m.role === "assistant") return `[Assistant]: ${m.content}`;
46
+ return m.content;
47
+ }).join("\n\n");
48
+ }
49
+
50
+ it("returns content directly for single user message", () => {
51
+ expect(flattenMessages([{ role: "user", content: "hello" }])).toBe("hello");
52
+ });
53
+
54
+ it("prefixes system messages", () => {
55
+ const result = flattenMessages([
56
+ { role: "system", content: "Be brief" },
57
+ { role: "user", content: "hi" },
58
+ ]);
59
+ expect(result).toContain("[System]: Be brief");
60
+ expect(result).toContain("hi");
61
+ });
62
+
63
+ it("prefixes assistant turns in multi-turn", () => {
64
+ const result = flattenMessages([
65
+ { role: "user", content: "Hello" },
66
+ { role: "assistant", content: "Hi there" },
67
+ { role: "user", content: "How are you?" },
68
+ ]);
69
+ expect(result).toContain("[Assistant]: Hi there");
70
+ expect(result).toContain("How are you?");
71
+ });
72
+ });
73
+
74
+ describe("claude-browser — proxy routing (DI override)", () => {
75
+ // Test that web-claude/* models route correctly through the proxy
76
+ // using the same DI pattern as grok-proxy.test.ts
77
+
78
+ it("web-claude/* model IDs follow naming convention", () => {
79
+ const validModels = [
80
+ "web-claude/claude-sonnet",
81
+ "web-claude/claude-opus",
82
+ "web-claude/claude-haiku",
83
+ ];
84
+ for (const m of validModels) {
85
+ expect(m.startsWith("web-claude/")).toBe(true);
86
+ }
87
+ });
88
+
89
+ it("distinguishes web-claude from web-grok", () => {
90
+ expect("web-claude/claude-sonnet".startsWith("web-claude/")).toBe(true);
91
+ expect("web-grok/grok-3".startsWith("web-claude/")).toBe(false);
92
+ });
93
+ });
@@ -0,0 +1,235 @@
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
+ // ── Stub types matching claude-browser exports ────────────────────────────────
15
+ type ClaudeCompleteOptions = {
16
+ messages: { role: string; content: string }[];
17
+ model?: string;
18
+ timeoutMs?: number;
19
+ };
20
+ type ClaudeCompleteResult = { content: string; model: string; finishReason: string };
21
+
22
+ // ── Stubs ─────────────────────────────────────────────────────────────────────
23
+ const stubClaudeComplete = vi.fn(async (
24
+ _ctx: BrowserContext,
25
+ opts: ClaudeCompleteOptions,
26
+ _log: (msg: string) => void
27
+ ): Promise<ClaudeCompleteResult> => ({
28
+ content: `claude mock: ${opts.messages[opts.messages.length - 1]?.content ?? ""}`,
29
+ model: opts.model ?? "web-claude/claude-sonnet",
30
+ finishReason: "stop",
31
+ }));
32
+
33
+ const stubClaudeCompleteStream = vi.fn(async (
34
+ _ctx: BrowserContext,
35
+ opts: ClaudeCompleteOptions,
36
+ onToken: (t: string) => void,
37
+ _log: (msg: string) => void
38
+ ): Promise<ClaudeCompleteResult> => {
39
+ const tokens = ["claude ", "stream ", "mock"];
40
+ for (const t of tokens) onToken(t);
41
+ return { content: tokens.join(""), model: opts.model ?? "web-claude/claude-sonnet", finishReason: "stop" };
42
+ });
43
+
44
+ // ── HTTP helper ───────────────────────────────────────────────────────────────
45
+ async function httpPost(
46
+ url: string,
47
+ body: unknown,
48
+ headers: Record<string, string> = {}
49
+ ): Promise<{ status: number; body: unknown }> {
50
+ return new Promise((resolve, reject) => {
51
+ const data = JSON.stringify(body);
52
+ const urlObj = new URL(url);
53
+ const req = http.request(
54
+ { hostname: urlObj.hostname, port: Number(urlObj.port), path: urlObj.pathname, method: "POST",
55
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data), ...headers } },
56
+ (res) => {
57
+ let raw = "";
58
+ res.on("data", (c) => (raw += c));
59
+ res.on("end", () => {
60
+ try { resolve({ status: res.statusCode ?? 0, body: JSON.parse(raw) }); }
61
+ catch { resolve({ status: res.statusCode ?? 0, body: raw }); }
62
+ });
63
+ }
64
+ );
65
+ req.on("error", reject);
66
+ req.write(data);
67
+ req.end();
68
+ });
69
+ }
70
+
71
+ async function httpGet(url: string): Promise<{ status: number; body: unknown }> {
72
+ return new Promise((resolve, reject) => {
73
+ const urlObj = new URL(url);
74
+ const req = http.request(
75
+ { hostname: urlObj.hostname, port: Number(urlObj.port), path: urlObj.pathname, method: "GET" },
76
+ (res) => {
77
+ let raw = "";
78
+ res.on("data", (c) => (raw += c));
79
+ res.on("end", () => {
80
+ try { resolve({ status: res.statusCode ?? 0, body: JSON.parse(raw) }); }
81
+ catch { resolve({ status: res.statusCode ?? 0, body: raw }); }
82
+ });
83
+ }
84
+ );
85
+ req.on("error", reject);
86
+ req.end();
87
+ });
88
+ }
89
+
90
+ // ── Fake context ──────────────────────────────────────────────────────────────
91
+ const fakeClaudeCtx = {} as BrowserContext;
92
+
93
+ // ── Server setup ──────────────────────────────────────────────────────────────
94
+ let server: http.Server;
95
+ let baseUrl: string;
96
+
97
+ beforeAll(async () => {
98
+ server = await startProxyServer({
99
+ port: 0,
100
+ log: () => {},
101
+ warn: () => {},
102
+ getClaudeContext: () => fakeClaudeCtx,
103
+ // @ts-expect-error — stub types close enough for testing
104
+ _claudeComplete: stubClaudeComplete,
105
+ // @ts-expect-error — stub types close enough for testing
106
+ _claudeCompleteStream: stubClaudeCompleteStream,
107
+ });
108
+ const addr = server.address() as AddressInfo;
109
+ baseUrl = `http://127.0.0.1:${addr.port}`;
110
+ });
111
+
112
+ afterAll(() => server.close());
113
+
114
+ // ── Tests ─────────────────────────────────────────────────────────────────────
115
+
116
+ describe("Claude web-session routing — model list", () => {
117
+ it("includes web-claude/* models in /v1/models", async () => {
118
+ const res = await httpGet(`${baseUrl}/v1/models`);
119
+ expect(res.status).toBe(200);
120
+ const data = res.body as { data: { id: string }[] };
121
+ const ids = data.data.map((m) => m.id);
122
+ expect(ids).toContain("web-claude/claude-sonnet");
123
+ expect(ids).toContain("web-claude/claude-opus");
124
+ expect(ids).toContain("web-claude/claude-haiku");
125
+ });
126
+
127
+ it("web-claude/* models listed in CLI_MODELS", () => {
128
+ const ids = CLI_MODELS.map((m) => m.id);
129
+ expect(ids.some((id) => id.startsWith("web-claude/"))).toBe(true);
130
+ });
131
+ });
132
+
133
+ describe("Claude web-session routing — non-streaming", () => {
134
+ it("returns assistant message for web-claude/claude-sonnet", async () => {
135
+ const res = await httpPost(`${baseUrl}/v1/chat/completions`, {
136
+ model: "web-claude/claude-sonnet",
137
+ messages: [{ role: "user", content: "hello" }],
138
+ stream: false,
139
+ });
140
+ expect(res.status).toBe(200);
141
+ const body = res.body as { choices: { message: { content: string } }[] };
142
+ expect(body.choices[0].message.content).toContain("claude mock");
143
+ expect(body.choices[0].message.content).toContain("hello");
144
+ });
145
+
146
+ it("calls stubClaudeComplete with correct model and messages", async () => {
147
+ stubClaudeComplete.mockClear();
148
+ await httpPost(`${baseUrl}/v1/chat/completions`, {
149
+ model: "web-claude/claude-opus",
150
+ messages: [{ role: "user", content: "test" }],
151
+ stream: false,
152
+ });
153
+ expect(stubClaudeComplete).toHaveBeenCalledOnce();
154
+ const call = stubClaudeComplete.mock.calls[0];
155
+ expect(call[0]).toBe(fakeClaudeCtx);
156
+ expect(call[1].model).toBe("web-claude/claude-opus");
157
+ });
158
+
159
+ it("returns 503 when no claude context is available", async () => {
160
+ const noCtxServer = await startProxyServer({
161
+ port: 0, log: () => {}, warn: () => {},
162
+ getClaudeContext: () => null,
163
+ });
164
+ const addr = noCtxServer.address() as AddressInfo;
165
+ const noCtxUrl = `http://127.0.0.1:${addr.port}`;
166
+ const res = await httpPost(`${noCtxUrl}/v1/chat/completions`, {
167
+ model: "web-claude/claude-sonnet",
168
+ messages: [{ role: "user", content: "hi" }],
169
+ });
170
+ expect(res.status).toBe(503);
171
+ const body = res.body as { error: { code: string } };
172
+ expect(body.error.code).toBe("no_claude_session");
173
+ noCtxServer.close();
174
+ });
175
+ });
176
+
177
+ describe("Claude web-session routing — streaming", () => {
178
+ it("returns SSE stream for web-claude/claude-sonnet", async () => {
179
+ return new Promise<void>((resolve, reject) => {
180
+ const body = JSON.stringify({
181
+ model: "web-claude/claude-sonnet",
182
+ messages: [{ role: "user", content: "stream test" }],
183
+ stream: true,
184
+ });
185
+ const urlObj = new URL(`${baseUrl}/v1/chat/completions`);
186
+ const req = http.request(
187
+ { hostname: urlObj.hostname, port: Number(urlObj.port), path: urlObj.pathname, method: "POST",
188
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) } },
189
+ (res) => {
190
+ expect(res.statusCode).toBe(200);
191
+ expect(res.headers["content-type"]).toContain("text/event-stream");
192
+ let raw = "";
193
+ res.on("data", (c) => (raw += c));
194
+ res.on("end", () => {
195
+ expect(raw).toContain("data:");
196
+ expect(raw).toContain("[DONE]");
197
+ resolve();
198
+ });
199
+ }
200
+ );
201
+ req.on("error", reject);
202
+ req.write(body);
203
+ req.end();
204
+ });
205
+ });
206
+
207
+ it("streams tokens from stub", async () => {
208
+ stubClaudeCompleteStream.mockClear();
209
+ return new Promise<void>((resolve, reject) => {
210
+ const body = JSON.stringify({
211
+ model: "web-claude/claude-haiku",
212
+ messages: [{ role: "user", content: "tokens" }],
213
+ stream: true,
214
+ });
215
+ const urlObj = new URL(`${baseUrl}/v1/chat/completions`);
216
+ const req = http.request(
217
+ { hostname: urlObj.hostname, port: Number(urlObj.port), path: urlObj.pathname, method: "POST",
218
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) } },
219
+ (res) => {
220
+ let raw = "";
221
+ res.on("data", (c) => (raw += c));
222
+ res.on("end", () => {
223
+ expect(stubClaudeCompleteStream).toHaveBeenCalledOnce();
224
+ // Verify stream contains token chunks
225
+ expect(raw).toContain("claude stream mock".split(" ")[0]);
226
+ resolve();
227
+ });
228
+ }
229
+ );
230
+ req.on("error", reject);
231
+ req.write(body);
232
+ req.end();
233
+ });
234
+ });
235
+ });
@@ -173,15 +173,26 @@ describe("routeToCliRunner — model normalization", () => {
173
173
  });
174
174
 
175
175
  it("accepts cli-claude/ without vllm prefix (calls runClaude path)", async () => {
176
- await expect(
177
- routeToCliRunner("cli-claude/claude-sonnet-4-6", [{ role: "user", content: "hi" }], 500)
178
- ).rejects.not.toThrow("Unknown CLI bridge model");
176
+ // Claude CLI may resolve (empty) or reject — what matters is it doesn't throw "Unknown CLI bridge model"
177
+ let errorMsg = "";
178
+ try {
179
+ await routeToCliRunner("cli-claude/claude-sonnet-4-6", [{ role: "user", content: "hi" }], 500);
180
+ } catch (e: any) {
181
+ errorMsg = (e as Error).message ?? String(e);
182
+ }
183
+ expect(errorMsg).not.toContain("Unknown CLI bridge model");
184
+ expect(errorMsg).not.toContain("CLI bridge model not allowed");
179
185
  });
180
186
 
181
187
  it("accepts vllm/cli-claude/ — strips vllm prefix before routing", async () => {
182
- await expect(
183
- routeToCliRunner("vllm/cli-claude/claude-sonnet-4-6", [{ role: "user", content: "hi" }], 500)
184
- ).rejects.not.toThrow("Unknown CLI bridge model");
188
+ let errorMsg = "";
189
+ try {
190
+ await routeToCliRunner("vllm/cli-claude/claude-sonnet-4-6", [{ role: "user", content: "hi" }], 500);
191
+ } catch (e: any) {
192
+ errorMsg = (e as Error).message ?? String(e);
193
+ }
194
+ expect(errorMsg).not.toContain("Unknown CLI bridge model");
195
+ expect(errorMsg).not.toContain("CLI bridge model not allowed");
185
196
  });
186
197
 
187
198
  // T-101: gemini routing paths
@@ -258,12 +269,17 @@ describe("routeToCliRunner — model allowlist (T-103)", () => {
258
269
  });
259
270
 
260
271
  it("allowedModels: null disables the check — only routing matters", async () => {
261
- // With null allowlist, unknown-prefix models still throw "Unknown CLI bridge model"
262
- await expect(
263
- routeToCliRunner("vllm/cli-claude/any-model", [{ role: "user", content: "hi" }], 500, {
272
+ // With null allowlist, the allowlist check is skipped routing still happens
273
+ // Claude CLI may resolve (empty) or reject for other reasons — should NOT throw "CLI bridge model not allowed"
274
+ let errorMsg = "";
275
+ try {
276
+ await routeToCliRunner("vllm/cli-claude/any-model", [{ role: "user", content: "hi" }], 500, {
264
277
  allowedModels: null,
265
- })
266
- ).rejects.not.toThrow("CLI bridge model not allowed");
278
+ });
279
+ } catch (e: any) {
280
+ errorMsg = (e as Error).message ?? String(e);
281
+ }
282
+ expect(errorMsg).not.toContain("CLI bridge model not allowed");
267
283
  });
268
284
 
269
285
  it("custom allowlist overrides defaults", async () => {