@elvatis_com/openclaw-cli-bridge-elvatis 1.5.1 → 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.
- package/CONTRIBUTING.md +93 -0
- package/README.md +34 -3
- package/SKILL.md +13 -4
- package/index.ts +528 -7
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/proxy-server.ts +127 -0
- package/test/chatgpt-proxy.test.ts +153 -0
- package/test/claude-proxy.test.ts +140 -0
package/CONTRIBUTING.md
ADDED
|
@@ -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
|
+
**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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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:
|
|
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
|
|
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(", ")}`);
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "openclaw-cli-bridge-elvatis",
|
|
3
3
|
"name": "OpenClaw CLI Bridge",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.6.1",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Phase 1: openai-codex auth bridge. Phase 2: local HTTP proxy routing model calls through gemini/claude CLIs (vllm provider).",
|
|
7
7
|
"providers": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elvatis_com/openclaw-cli-bridge-elvatis",
|
|
3
|
-
"version": "1.
|
|
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": {
|
package/src/proxy-server.ts
CHANGED
|
@@ -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
|
+
});
|