li2895 commited on
Commit
845178e
·
verified ·
1 Parent(s): fa084d8

Upload 6 files

Browse files
Files changed (6) hide show
  1. Dockerfile +18 -0
  2. extract_token.py +129 -0
  3. extract_token_remote.py +205 -0
  4. get_token.py +402 -0
  5. requirements.txt +6 -0
  6. server.py +822 -0
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY server.py extract_token.py extract_token_remote.py get_token.py ./
9
+
10
+ # HF Spaces 需要监听 7860 端口,/data 是持久化目录
11
+ ENV PROXY_PORT=7860
12
+ ENV TOKEN_FILE_PATH=/data/token.json
13
+
14
+ RUN mkdir -p /data
15
+
16
+ EXPOSE 7860
17
+
18
+ CMD ["python", "server.py"]
extract_token.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ 从运行中的 WorkBuddy 提取 Token(通过 CDP)。
4
+
5
+ 用法:
6
+ python extract_token.py # 默认 CDP 端口 9222
7
+ python extract_token.py --port 9223 # 自定义端口
8
+ python extract_token.py --save # 提取并保存到 data/token.json
9
+
10
+ 前提: WorkBuddy 以 --remote-debugging-port=9222 启动。
11
+ """
12
+
13
+ import argparse
14
+ import asyncio
15
+ import json
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ if sys.platform == "win32":
20
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
21
+
22
+ try:
23
+ import httpx
24
+ except ImportError:
25
+ sys.exit("请先安装依赖: pip install httpx")
26
+
27
+
28
+ async def extract(cdp_port: int = 9222) -> dict | None:
29
+ base = f"http://127.0.0.1:{cdp_port}"
30
+ async with httpx.AsyncClient() as client:
31
+ try:
32
+ resp = await client.get(f"{base}/json", timeout=5)
33
+ except httpx.ConnectError:
34
+ print(f"无法连接 CDP ({base}),请确认 WorkBuddy 已用调试模式启动")
35
+ return None
36
+ targets = resp.json()
37
+
38
+ ws_url = None
39
+ for t in targets:
40
+ if t.get("type") == "page" and "workbench" in t.get("url", ""):
41
+ ws_url = t.get("webSocketDebuggerUrl")
42
+ break
43
+ if not ws_url:
44
+ for t in targets:
45
+ if t.get("type") == "page":
46
+ ws_url = t.get("webSocketDebuggerUrl")
47
+ break
48
+ if not ws_url:
49
+ print("未找到可用的 CDP 页面目标")
50
+ return None
51
+
52
+ try:
53
+ import websockets
54
+ except ImportError:
55
+ sys.exit("请安装 websockets: pip install websockets")
56
+
57
+ async with websockets.connect(ws_url) as ws:
58
+ cmd = {
59
+ "id": 1,
60
+ "method": "Runtime.evaluate",
61
+ "params": {
62
+ "expression": """
63
+ (async () => {
64
+ try {
65
+ const s = await window.vscode.ipcRenderer.invoke(
66
+ 'vscode:genie:auth:getSession'
67
+ );
68
+ return JSON.stringify(s);
69
+ } catch(e) {
70
+ return JSON.stringify({error: e.message});
71
+ }
72
+ })()
73
+ """,
74
+ "awaitPromise": True,
75
+ "returnByValue": True,
76
+ },
77
+ }
78
+ await ws.send(json.dumps(cmd))
79
+ result = json.loads(await asyncio.wait_for(ws.recv(), timeout=10))
80
+
81
+ value = result.get("result", {}).get("result", {}).get("value", "")
82
+ if not value:
83
+ print("CDP 返回为空")
84
+ return None
85
+
86
+ session = json.loads(value)
87
+ if session.get("error"):
88
+ print(f"CDP 错误: {session['error']}")
89
+ return None
90
+
91
+ return session
92
+
93
+
94
+ def main():
95
+ parser = argparse.ArgumentParser(description="从 WorkBuddy 提取 Token")
96
+ parser.add_argument("--port", type=int, default=9222, help="CDP 端口 (默认 9222)")
97
+ parser.add_argument("--save", action="store_true", help="保存到 data/token.json")
98
+ args = parser.parse_args()
99
+
100
+ session = asyncio.run(extract(args.port))
101
+ if not session:
102
+ sys.exit(1)
103
+
104
+ # 兼容新版(嵌套 auth)和旧版(扁平)结构
105
+ auth = session.get("auth", session)
106
+ access_token = auth.get("accessToken", "")
107
+ refresh_token = auth.get("refreshToken", "")
108
+
109
+ if not access_token:
110
+ print("未获取到 accessToken")
111
+ sys.exit(1)
112
+
113
+ print(f"accessToken: {access_token[:40]}... (len={len(access_token)})")
114
+ print(f"refreshToken: {refresh_token[:40]}... (len={len(refresh_token)})")
115
+
116
+ if args.save:
117
+ import time
118
+ out = Path(__file__).parent / "data" / "token.json"
119
+ out.parent.mkdir(parents=True, exist_ok=True)
120
+ out.write_text(json.dumps({
121
+ "access_token": access_token,
122
+ "refresh_token": refresh_token,
123
+ "saved_at": time.strftime("%Y-%m-%d %H:%M:%S"),
124
+ }, indent=2))
125
+ print(f"\n已保存到 {out}")
126
+
127
+
128
+ if __name__ == "__main__":
129
+ main()
extract_token_remote.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ 从远程 WorkBuddy 实例提取 Token。
4
+
5
+ 支持两种模式:
6
+ 1. 本地模式:自动检测本机 WorkBuddy
7
+ 2. 远程模式:连接远程 Mac 的 CDP 端口
8
+
9
+ 用法:
10
+ # 本机自动检测
11
+ python extract_token_remote.py
12
+
13
+ # 远程 Mac(需要先在目标机器启动 WorkBuddy 调试模式)
14
+ python extract_token_remote.py --host 192.168.1.100 --port 9222 --save
15
+
16
+ 前置条件(远程 Mac):
17
+ /Applications/WorkBuddy.app/Contents/MacOS/Electron --remote-debugging-port=9222
18
+
19
+ 注意:可能需要配置防火墙允许 9222 端口入站。
20
+ """
21
+
22
+ import argparse
23
+ import asyncio
24
+ import json
25
+ import sys
26
+ import time
27
+ from pathlib import Path
28
+
29
+ if sys.platform == "win32":
30
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
31
+
32
+ try:
33
+ import httpx
34
+ except ImportError:
35
+ sys.exit("请先安装依赖: pip install httpx")
36
+
37
+ try:
38
+ import websockets
39
+ except ImportError:
40
+ sys.exit("请先安装依赖: pip install websockets")
41
+
42
+
43
+ async def extract(cdp_host: str = "127.0.0.1", cdp_port: int = 9222) -> dict | None:
44
+ base = f"http://{cdp_host}:{cdp_port}"
45
+
46
+ print(f"正在连接 CDP ({base})...")
47
+
48
+ async with httpx.AsyncClient() as client:
49
+ try:
50
+ resp = await client.get(f"{base}/json", timeout=10)
51
+ except httpx.ConnectError as e:
52
+ print(f"❌ 无法连接 CDP: {e}")
53
+ print(f"\n请确保远程 Mac 已启动 WorkBuddy 调试模式:")
54
+ print(f" /Applications/WorkBuddy.app/Contents/MacOS/Electron --remote-debugging-port={cdp_port}")
55
+ return None
56
+ targets = resp.json()
57
+
58
+ ws_url = None
59
+ for t in targets:
60
+ if t.get("type") == "page" and "workbench" in t.get("url", ""):
61
+ ws_url = t.get("webSocketDebuggerUrl")
62
+ if ws_url:
63
+ if cdp_host != "127.0.0.1" and cdp_host != "localhost":
64
+ ws_url = ws_url.replace("127.0.0.1", cdp_host)
65
+ print(f"✅ 找到 Workbench 页面")
66
+ break
67
+
68
+ if not ws_url:
69
+ for t in targets:
70
+ if t.get("type") == "page":
71
+ ws_url = t.get("webSocketDebuggerUrl")
72
+ if ws_url:
73
+ if cdp_host != "127.0.0.1" and cdp_host != "localhost":
74
+ ws_url = ws_url.replace("127.0.0.1", cdp_host)
75
+ print(f"⚠️ 未找到 Workbench,找到通用页面")
76
+ break
77
+
78
+ if not ws_url:
79
+ print("❌ 未找到可用的 CDP 页面目标")
80
+ return None
81
+
82
+ print(f"正在通过 WebSocket 提取 Token...")
83
+
84
+ async with websockets.connect(ws_url, ping_timeout=30) as ws:
85
+ cmd = {
86
+ "id": 1,
87
+ "method": "Runtime.evaluate",
88
+ "params": {
89
+ "expression": """
90
+ (async () => {
91
+ try {
92
+ const s = await window.vscode.ipcRenderer.invoke(
93
+ 'vscode:genie:auth:getSession'
94
+ );
95
+ return JSON.stringify(s);
96
+ } catch(e) {
97
+ return JSON.stringify({error: e.message});
98
+ }
99
+ })()
100
+ """,
101
+ "awaitPromise": True,
102
+ "returnByValue": True,
103
+ },
104
+ }
105
+ await ws.send(json.dumps(cmd))
106
+ result = json.loads(await asyncio.wait_for(ws.recv(), timeout=30))
107
+
108
+ value = result.get("result", {}).get("result", {}).get("value", "")
109
+ if not value:
110
+ print("❌ CDP 返回为空")
111
+ return None
112
+
113
+ session = json.loads(value)
114
+ if session.get("error"):
115
+ print(f"❌ CDP 错误: {session['error']}")
116
+ return None
117
+
118
+ return session
119
+
120
+
121
+ def main():
122
+ parser = argparse.ArgumentParser(
123
+ description="从 WorkBuddy 提取 Token(支持远程)",
124
+ formatter_class=argparse.RawDescriptionHelpFormatter,
125
+ epilog="""
126
+ 示例:
127
+ # 本机自动检测
128
+ python extract_token_remote.py
129
+
130
+ # 远程 Mac
131
+ python extract_token_remote.py --host 192.168.1.100 --save
132
+
133
+ # 自定义端口
134
+ python extract_token_remote.py --host 192.168.1.100 --port 9223 --save
135
+ """
136
+ )
137
+ parser.add_argument(
138
+ "--host", "-H",
139
+ default="127.0.0.1",
140
+ help="CDP 主机地址 (默认: 127.0.0.1)"
141
+ )
142
+ parser.add_argument(
143
+ "--port", "-p",
144
+ type=int,
145
+ default=9222,
146
+ help="CDP 端口 (默认: 9222)"
147
+ )
148
+ parser.add_argument(
149
+ "--save", "-s",
150
+ action="store_true",
151
+ help="保存到 data/token.json"
152
+ )
153
+ parser.add_argument(
154
+ "--output", "-o",
155
+ help="保存到指定文件 (默认: data/token.json)"
156
+ )
157
+ args = parser.parse_args()
158
+
159
+ is_remote = args.host not in ("127.0.0.1", "localhost")
160
+
161
+ if is_remote:
162
+ print(f"\n🔗 远程模式")
163
+ print(f" 目标: {args.host}:{args.port}")
164
+ print(f" 注意: 确保目标 Mac 已启动 WorkBuddy 调试模式\n")
165
+ else:
166
+ print(f"\n🔗 本地模式\n")
167
+
168
+ session = asyncio.run(extract(args.host, args.port))
169
+ if not session:
170
+ sys.exit(1)
171
+
172
+ auth = session.get("auth", session)
173
+ access_token = auth.get("accessToken", "")
174
+ refresh_token = auth.get("refreshToken", "")
175
+
176
+ if not access_token:
177
+ print("❌ 未获取到 accessToken")
178
+ sys.exit(1)
179
+
180
+ print(f"\n✅ Token 提取成功!")
181
+ print(f" accessToken: {access_token[:40]}... (len={len(access_token)})")
182
+ print(f" refreshToken: {refresh_token[:40] if refresh_token else 'N/A'}... "
183
+ f"(len={len(refresh_token) if refresh_token else 0})")
184
+
185
+ if args.save or args.output:
186
+ import time as time_module
187
+ out_path = Path(args.output) if args.output else Path(__file__).parent / "data" / "token.json"
188
+ out_path.parent.mkdir(parents=True, exist_ok=True)
189
+ out_path.write_text(json.dumps({
190
+ "access_token": access_token,
191
+ "refresh_token": refresh_token,
192
+ "saved_at": time_module.strftime("%Y-%m-%d %H:%M:%S"),
193
+ "source_host": args.host if is_remote else "localhost",
194
+ "source_port": args.port,
195
+ }, indent=2))
196
+ print(f"\n💾 已保存到: {out_path}")
197
+
198
+ if is_remote:
199
+ print(f"\n📋 接下来在本地 Mac 上:")
200
+ print(f" 1. 将生成的 data/token.json 复制到本项目")
201
+ print(f" 2. 启动代理: python server.py")
202
+
203
+
204
+ if __name__ == "__main__":
205
+ main()
get_token.py ADDED
@@ -0,0 +1,402 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ WorkBuddy Token 提取工具(傻瓜版)
4
+
5
+ 全自动检测和安装依赖,用户只需双击运行即可。
6
+
7
+ 功能:
8
+ - 自动检测并安装 Python(如未安装)
9
+ - 自动安装脚本依赖
10
+ - 自动启动 WorkBuddy(调试模式)
11
+ - 自动提取 Token 并保存到桌面
12
+ """
13
+
14
+ import os
15
+ import sys
16
+ import time
17
+ import json
18
+ import subprocess
19
+ import asyncio
20
+ import platform
21
+ from pathlib import Path
22
+
23
+
24
+ def run_cmd(cmd, check=True, shell=True):
25
+ """执行命令并返回结果"""
26
+ try:
27
+ result = subprocess.run(
28
+ cmd, shell=shell, capture_output=True, text=True, timeout=300
29
+ )
30
+ if check and result.returncode != 0:
31
+ return False, result.stderr
32
+ return True, result.stdout
33
+ except subprocess.TimeoutExpired:
34
+ return False, "命令执行超时"
35
+ except Exception as e:
36
+ return False, str(e)
37
+
38
+
39
+ def check_python():
40
+ """检查 Python 是否安装"""
41
+ py_names = ["python", "python3"] if platform.system() == "Windows" else ["python3", "python"]
42
+ for name in py_names:
43
+ try:
44
+ result = subprocess.run(
45
+ [name, "--version"], capture_output=True, text=True
46
+ )
47
+ if result.returncode == 0:
48
+ version = result.stdout.strip()
49
+ print(f" ✅ Python 已安装: {version}")
50
+ return True
51
+ except FileNotFoundError:
52
+ continue
53
+
54
+ print(" ❌ Python 未安装,正在安装...")
55
+ return False
56
+
57
+
58
+ def install_homebrew():
59
+ """安装 Homebrew"""
60
+ print("\n正在安装 Homebrew...")
61
+ print("(这可能需要几分钟,请耐心等待)\n")
62
+
63
+ cmd = '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
64
+ ok, msg = run_cmd(cmd, check=False)
65
+
66
+ if ok:
67
+ print(" ✅ Homebrew 安装成功!")
68
+ return True
69
+ else:
70
+ print(f" ⚠️ Homebrew 安装失败")
71
+ print("\n请手动安装 Homebrew:")
72
+ print(" 打开终端,粘贴以下命令:")
73
+ print(" /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"")
74
+ return False
75
+
76
+
77
+ def install_python_via_installer():
78
+ """通过 python.org 安装包安装 Python(不需要 sudo)"""
79
+ print("\n正在下载 Python 安装包...")
80
+ print("(这可能需要几分钟,取决于网速)\n")
81
+
82
+ dmg_url = "https://www.python.org/ftp/python/3.13.0/python-3.13.0-macos11.pkg"
83
+ dmg_path = "/tmp/python-installer.pkg"
84
+
85
+ ok, _ = run_cmd(f'curl -L -o "{dmg_path}" "{dmg_url}"', check=False)
86
+ if not ok:
87
+ print(" ❌ 下载失败,请检查网络连接")
88
+ return False
89
+
90
+ print(" ✅ 下载完成,正在打开安装程序...")
91
+ print("\n" + "=" * 50)
92
+ print(" 请在弹出的安装程序中:")
93
+ print(" 1. 点击「继续」")
94
+ print(" 2. 阅读许可协议,点击「继续」→「同意」")
95
+ print(" 3. 选择安装位置,点击「安装」")
96
+ print(" 4. 输入电脑密码(如果有)")
97
+ print(" 5. 等待安装完成,点击「关闭」")
98
+ print("=" * 50)
99
+ print("\n安装完成后,回到此窗口按回车键继续...")
100
+ print("(或者输入 q 退出,稍后手动安装)")
101
+
102
+ response = input("\n按回车继续,或输入 q 退出: ").strip().lower()
103
+ if response == 'q':
104
+ return False
105
+
106
+ run_cmd(f'open "{dmg_path}"', check=False)
107
+
108
+ input("\n安装完成后,按回车继续...")
109
+ return True
110
+
111
+
112
+ def install_python():
113
+ """安装 Python"""
114
+ print("\n" + "=" * 50)
115
+ print("正在安装 Python...")
116
+ print("=" * 50)
117
+
118
+ success, _ = run_cmd("which brew")
119
+ if not success:
120
+ print(" ❌ 未检测到 Homebrew")
121
+
122
+ print("\n尝试通过安装包安装 Python(不需要 sudo)...")
123
+ if install_python_via_installer():
124
+ ok, _ = run_cmd("which python3", check=False)
125
+ if ok:
126
+ print(" ✅ Python 安装成功!")
127
+ return True
128
+
129
+ print("\n正在安装 Homebrew 作为备选...")
130
+ if not install_homebrew():
131
+ print("\n⚠️ 无法自动安装 Python")
132
+ print("请手动安装: https://www.python.org/downloads/")
133
+ return False
134
+
135
+ print("\n重新检测 Homebrew...")
136
+ success, _ = run_cmd("which brew")
137
+ if not success:
138
+ print(" ❌ Homebrew 安装失败")
139
+ return False
140
+
141
+ print("使用 Homebrew 安装 Python...")
142
+ ok, msg = run_cmd("brew install python", check=False)
143
+ if ok:
144
+ print(" ✅ Python 安装成功!")
145
+ return True
146
+ else:
147
+ print(f" ⚠️ Homebrew 安装 Python 失败")
148
+ print("尝试通过安装包安装...")
149
+
150
+ if install_python_via_installer():
151
+ ok, _ = run_cmd("which python3", check=False)
152
+ if ok:
153
+ print(" ✅ Python 安装成功!")
154
+ return True
155
+
156
+ return False
157
+
158
+
159
+ def install_dependencies():
160
+ """安装脚本依赖"""
161
+ print("\n正在安装依赖 (httpx, websockets)...")
162
+
163
+ pip_cmd = "pip" if platform.system() == "Windows" else "pip3"
164
+ deps = ["httpx", "websockets"]
165
+ for dep in deps:
166
+ ok, _ = run_cmd(f"{pip_cmd} install {dep}")
167
+ if ok:
168
+ print(f" ✅ {dep} 安装成功")
169
+ else:
170
+ print(f" ❌ {dep} 安装失败")
171
+ return False
172
+ return True
173
+
174
+
175
+ def check_workbuddy_installed():
176
+ """检查 WorkBuddy 是否安装"""
177
+ paths = [
178
+ Path("/Applications/WorkBuddy.app"),
179
+ Path.home() / "Applications/WorkBuddy.app",
180
+ # Windows paths
181
+ Path(os.path.expandvars(r"%LOCALAPPDATA%\Programs\WorkBuddy\WorkBuddy.exe")),
182
+ Path(os.path.expandvars(r"%ProgramFiles%\WorkBuddy\WorkBuddy.exe")),
183
+ Path(os.path.expandvars(r"%ProgramFiles(x86)%\WorkBuddy\WorkBuddy.exe")),
184
+ Path(r"D:\Program Files\WorkBuddy\WorkBuddy.exe"),
185
+ Path(r"E:\Program Files\WorkBuddy\WorkBuddy.exe"),
186
+ ]
187
+ for p in paths:
188
+ if p.exists():
189
+ return True, p
190
+ return False, None
191
+
192
+
193
+ def check_workbuddy_running():
194
+ """检查 WorkBuddy 是否正在运行(调试模式)"""
195
+ try:
196
+ import httpx
197
+ resp = httpx.get("http://127.0.0.1:9222/json", timeout=2)
198
+ if resp.status_code == 200:
199
+ return True
200
+ except:
201
+ pass
202
+ return False
203
+
204
+
205
+ def start_workbuddy_debug():
206
+ """启动 WorkBuddy 调试模式"""
207
+ print("\n正在启动 WorkBuddy(调试模式)...")
208
+
209
+ if platform.system() == "Windows":
210
+ # Find WorkBuddy on Windows
211
+ candidates = [
212
+ os.path.expandvars(r"%LOCALAPPDATA%\Programs\WorkBuddy\WorkBuddy.exe"),
213
+ os.path.expandvars(r"%ProgramFiles%\WorkBuddy\WorkBuddy.exe"),
214
+ os.path.expandvars(r"%ProgramFiles(x86)%\WorkBuddy\WorkBuddy.exe"),
215
+ r"D:\Program Files\WorkBuddy\WorkBuddy.exe",
216
+ r"E:\Program Files\WorkBuddy\WorkBuddy.exe",
217
+ ]
218
+ workbuddy_path = next((p for p in candidates if os.path.exists(p)), None)
219
+ if not workbuddy_path:
220
+ print(" ❌ 找不到 WorkBuddy,请手动启动" )
221
+ print(" → 关闭 WorkBuddy,然后重新打开" )
222
+ print(" → 或者:Win+R 运行:workbuddy --remote-debugging-port=9222" )
223
+ return False
224
+ cmd = f'"{workbuddy_path}" --remote-debugging-port=9222'
225
+ else:
226
+ workbuddy_path = "/Applications/WorkBuddy.app/Contents/MacOS/Electron"
227
+ cmd = f'"{workbuddy_path}" --remote-debugging-port=9222'
228
+
229
+ try:
230
+ subprocess.Popen(cmd, shell=True)
231
+ print(" ✅ WorkBuddy 已启动")
232
+ print("\n等待 WorkBuddy 完全加载(5秒)...")
233
+ time.sleep(5)
234
+ return True
235
+ except Exception as e:
236
+ print(f" ❌ 启动失败: {e}")
237
+ return False
238
+
239
+
240
+ def check_requirements():
241
+ """检查前置条件"""
242
+ print("\n[检查 1/4] 检测 Python...")
243
+ if not check_python():
244
+ if not install_python():
245
+ return False
246
+
247
+ print("\n[检查 2/4] 安装依赖...")
248
+ if not install_dependencies():
249
+ return False
250
+
251
+ print("\n[检查 3/4] 检测 WorkBuddy...")
252
+ installed, path = check_workbuddy_installed()
253
+ if not installed:
254
+ print(" ❌ WorkBuddy 未安装")
255
+ print(" 请先安装 WorkBuddy: https://workbuddy.com/download")
256
+ return False
257
+ print(f" ✅ WorkBuddy 已安装: {path}")
258
+
259
+ print("\n[检查 4/4] 检测 WorkBuddy 运行状态...")
260
+ if check_workbuddy_running():
261
+ print(" ✅ WorkBuddy 已运行(调试模式)")
262
+ else:
263
+ print(" ⚠️ WorkBuddy 未运行,正在启动...")
264
+ if not start_workbuddy_debug():
265
+ return False
266
+
267
+ return True
268
+
269
+
270
+ async def extract_token():
271
+ """从 WorkBuddy 提取 Token"""
272
+ import httpx
273
+ import websockets
274
+
275
+ print("\n正在连接 WorkBuddy...")
276
+
277
+ async with httpx.AsyncClient() as client:
278
+ resp = await client.get("http://127.0.0.1:9222/json", timeout=5)
279
+ targets = resp.json()
280
+
281
+ print(f" ✅ 找到 {len(targets)} 个页面")
282
+
283
+ ws_url = None
284
+ for t in targets:
285
+ if t.get("type") == "page" and "workbench" in t.get("url", ""):
286
+ ws_url = t.get("webSocketDebuggerUrl")
287
+ break
288
+
289
+ if not ws_url:
290
+ for t in targets:
291
+ if t.get("type") == "page":
292
+ ws_url = t.get("webSocketDebuggerUrl")
293
+ break
294
+
295
+ if not ws_url:
296
+ raise Exception("未找到 WorkBuddy 页面")
297
+
298
+ print(" ✅ 已找到 WorkBuddy 页面")
299
+ print("\n���在提取 Token...")
300
+
301
+ async with websockets.connect(ws_url, ping_timeout=30) as ws:
302
+ cmd = {
303
+ "id": 1,
304
+ "method": "Runtime.evaluate",
305
+ "params": {
306
+ "expression": """
307
+ (async () => {
308
+ try {
309
+ const s = await window.vscode.ipcRenderer.invoke(
310
+ 'vscode:genie:auth:getSession'
311
+ );
312
+ return JSON.stringify(s);
313
+ } catch(e) {
314
+ return JSON.stringify({error: e.message});
315
+ }
316
+ })()
317
+ """,
318
+ "awaitPromise": True,
319
+ "returnByValue": True,
320
+ },
321
+ }
322
+ await ws.send(json.dumps(cmd))
323
+ result = json.loads(await asyncio.wait_for(ws.recv(), timeout=30))
324
+
325
+ value = result.get("result", {}).get("result", {}).get("value", "")
326
+ if not value:
327
+ raise Exception("CDP 返回为空")
328
+
329
+ session = json.loads(value)
330
+ if session.get("error"):
331
+ raise Exception(f"CDP 错误: {session['error']}")
332
+
333
+ return session
334
+
335
+
336
+ def save_token(access_token, refresh_token):
337
+ """保存 Token 到桌面"""
338
+ desktop = Path.home() / "Desktop"
339
+ output_file = desktop / "token.txt"
340
+
341
+ content = f"""WorkBuddy Token
342
+ 生成时间: {time.strftime("%Y-%m-%d %H:%M:%S")}
343
+
344
+ access_token={access_token}
345
+ refresh_token={refresh_token}
346
+ """
347
+
348
+ output_file.write_text(content, encoding="utf-8")
349
+ return output_file
350
+
351
+
352
+ def main():
353
+ print("=" * 50)
354
+ print(" WorkBuddy Token 提取工具")
355
+ print("=" * 50)
356
+ print("\n正在检测环境...\n")
357
+
358
+ if not check_requirements():
359
+ input("\n按回车键退出...")
360
+ sys.exit(1)
361
+
362
+ try:
363
+ session = asyncio.run(extract_token())
364
+ except Exception as e:
365
+ print(f"\n❌ 提取失败: {e}")
366
+ print("\n请确保:")
367
+ print(" 1. WorkBuddy 已登录")
368
+ print(" 2. 重新运行此脚本")
369
+ input("\n按回车键退出...")
370
+ sys.exit(1)
371
+
372
+ auth = session.get("auth", session)
373
+ access_token = auth.get("accessToken", "")
374
+ refresh_token = auth.get("refreshToken", "")
375
+
376
+ if not access_token:
377
+ print("\n❌ 未获取到 accessToken")
378
+ print("请确保 WorkBuddy 已登录 AI 功能")
379
+ input("\n按回车键退出...")
380
+ sys.exit(1)
381
+
382
+ try:
383
+ output_file = save_token(access_token, refresh_token)
384
+ except Exception as e:
385
+ print(f"\n❌ 保存失败: {e}")
386
+ input("\n按回车键退出...")
387
+ sys.exit(1)
388
+
389
+ print("\n" + "=" * 50)
390
+ print("✅ 成功!")
391
+ print("=" * 50)
392
+ print(f"\n📄 Token 已保存到桌面:")
393
+ print(f" {output_file}")
394
+ print(f"\n🔑 access_token: {access_token[:30]}...")
395
+ print(f"🔑 refresh_token: {refresh_token[:30] if refresh_token else 'N/A'}...")
396
+ print("\n" + "=" * 50)
397
+
398
+ input("\n按回车键退出...")
399
+
400
+
401
+ if __name__ == "__main__":
402
+ main()
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi>=0.115.0
2
+ uvicorn>=0.34.0
3
+ httpx>=0.28.0
4
+ pyjwt>=2.10.0
5
+ websockets>=14.0
6
+ python-dotenv>=1.0.0
server.py ADDED
@@ -0,0 +1,822 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ WorkBuddy → OpenAI-compatible reverse proxy.
3
+
4
+ Accepts standard OpenAI API requests and forwards them to WorkBuddy's
5
+ /v2/chat/completions endpoint with the required authentication headers.
6
+
7
+ All user-specific values (user_id, enterprise_id, domain) are automatically
8
+ extracted from the JWT token — no manual configuration required.
9
+
10
+ Usage:
11
+ python server.py # auto-extract via CDP
12
+ WB_TOKEN=<jwt> WB_REFRESH_TOKEN=<jwt> python server.py # manual token
13
+ """
14
+
15
+ import asyncio
16
+ import hmac
17
+ import json
18
+ import logging
19
+ import os
20
+ import re
21
+ import sys
22
+ import time
23
+ import uuid
24
+ from contextlib import asynccontextmanager
25
+ from pathlib import Path
26
+ from typing import AsyncGenerator
27
+
28
+ if sys.platform == "win32":
29
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
30
+
31
+ import httpx
32
+ import jwt
33
+ import uvicorn
34
+ from fastapi import FastAPI, HTTPException, Request
35
+ from fastapi.middleware.cors import CORSMiddleware
36
+ from fastapi.responses import JSONResponse, StreamingResponse
37
+
38
+ logging.basicConfig(
39
+ level=logging.INFO,
40
+ format="%(asctime)s [%(levelname)s] %(message)s",
41
+ datefmt="%H:%M:%S",
42
+ )
43
+ log = logging.getLogger("wb-proxy")
44
+
45
+ BASE_DIR = Path(__file__).parent
46
+
47
+ # README / .env.example:本地与 Docker 均通过 .env 配置;已有环境变量优先(不 override)
48
+ try:
49
+ from dotenv import load_dotenv
50
+
51
+ load_dotenv(BASE_DIR / ".env", override=False)
52
+ except ImportError:
53
+ pass
54
+
55
+ TOKEN_FILE = Path(os.getenv("TOKEN_FILE_PATH", str(BASE_DIR / "data" / "token.json")))
56
+
57
+ PROXY_PORT = int(os.getenv("PROXY_PORT", "19090"))
58
+ PROXY_API_KEY = os.getenv("PROXY_API_KEY", "").strip()
59
+ WB_API_BASE = os.getenv("WB_API_BASE", "https://copilot.tencent.com")
60
+ CDP_URL = os.getenv("CDP_URL", "http://127.0.0.1:9222")
61
+
62
+
63
+ def _detect_wb_version() -> str:
64
+ """Auto-detect genieVersion from local WorkBuddy installation."""
65
+ candidates = [
66
+ # macOS
67
+ Path("/Applications/WorkBuddy.app/Contents/Resources/app/product.json"),
68
+ # Windows — common locations
69
+ Path(os.path.expandvars(r"%LOCALAPPDATA%\Programs\WorkBuddy\resources\app\product.json")),
70
+ Path(os.path.expandvars(r"%ProgramFiles%\WorkBuddy\resources\app\product.json")),
71
+ Path(os.path.expandvars(r"%ProgramFiles(x86)%\WorkBuddy\resources\app\product.json")),
72
+ Path(os.path.expandvars(r"%APPDATA%\WorkBuddy\resources\app\product.json")),
73
+ # Linux (snap / deb)
74
+ Path(os.path.expanduser("~/.local/share/WorkBuddy/resources/app/product.json")),
75
+ Path("/opt/WorkBuddy/resources/app/product.json"),
76
+ ]
77
+ for p in candidates:
78
+ try:
79
+ data = json.loads(p.read_text(encoding="utf-8"))
80
+ v = data.get("genieVersion", "")
81
+ if v:
82
+ log.info(f"Detected WorkBuddy {v} at {p.parent}")
83
+ return v
84
+ except Exception:
85
+ continue
86
+ return ""
87
+
88
+
89
+ WB_VERSION = os.getenv("WB_VERSION", "") or _detect_wb_version() or "4.8.1"
90
+
91
+ HEADERS_TEMPLATE = {
92
+ "X-IDE-Type": "CodeBuddyIDE",
93
+ "X-IDE-Name": "CodeBuddyIDE",
94
+ "X-IDE-Version": WB_VERSION,
95
+ "X-Product-Version": WB_VERSION,
96
+ "X-Product": "SaaS",
97
+ "X-Env-ID": "production",
98
+ "X-Requested-With": "XMLHttpRequest",
99
+ "User-Agent": f"CodeBuddyIDE/{WB_VERSION} coding-copilot/{WB_VERSION}",
100
+ }
101
+
102
+ REASONING_MODELS = {"deepseek-r1", "deepseek-r1-0528-lkeap", "hunyuan-2.0-thinking-ioa", "hunyuan-2.0-thinking", "hy3-preview", "kimi-k2.6", "kimi-k2.6-ioa", "kimi-k2.7", "deepseek-v4-pro", "glm-5.2"}
103
+ DEFAULT_TIMEOUT = int(os.getenv("WB_TIMEOUT", "120"))
104
+
105
+ # WorkBuddy prepends "Thought: Xms\n" to each reasoning chunk — strip it
106
+ _THOUGHT_RE = re.compile(r"^Thought:\s*\d+ms\s*\n", re.MULTILINE)
107
+
108
+
109
+ def _clean_reasoning(text: str) -> str:
110
+ return _THOUGHT_RE.sub("", text)
111
+ REASONING_TIMEOUT = int(os.getenv("WB_REASONING_TIMEOUT", "300"))
112
+
113
+
114
+ def _parse_jwt_claims(token: str) -> dict:
115
+ """Extract user_id, enterprise_id and domain from JWT without verification."""
116
+ try:
117
+ payload = jwt.decode(token, options={"verify_signature": False})
118
+ user_id = payload.get("sub", "")
119
+
120
+ iss = payload.get("iss", "")
121
+ # iss format: https://<domain>/auth/realms/sso-<enterprise_id>
122
+ enterprise_id = ""
123
+ m = re.search(r"/sso-([^/]+)$", iss)
124
+ if m:
125
+ enterprise_id = m.group(1)
126
+
127
+ domain = ""
128
+ m2 = re.match(r"https?://([^/]+)", iss)
129
+ if m2:
130
+ domain = m2.group(1)
131
+
132
+ return {"user_id": user_id, "enterprise_id": enterprise_id, "domain": domain}
133
+ except Exception:
134
+ return {"user_id": "", "enterprise_id": "", "domain": ""}
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Token management
139
+ # ---------------------------------------------------------------------------
140
+ class TokenManager:
141
+ def __init__(self):
142
+ self.access_token: str = ""
143
+ self.refresh_token: str = ""
144
+ self.user_id: str = ""
145
+ self.enterprise_id: str = ""
146
+ self.domain: str = ""
147
+ self.department_info: str = ""
148
+ self._lock = asyncio.Lock()
149
+
150
+ async def init(self):
151
+ self.access_token = os.getenv("WB_TOKEN", "")
152
+ self.refresh_token = os.getenv("WB_REFRESH_TOKEN", "")
153
+
154
+ if not self.access_token:
155
+ self._load_from_file()
156
+
157
+ if not self.access_token:
158
+ await self._extract_from_cdp()
159
+
160
+ if self.access_token:
161
+ self._apply_claims()
162
+ self._log_token_info()
163
+ self._save_to_file()
164
+
165
+ def _apply_claims(self):
166
+ claims = _parse_jwt_claims(self.access_token)
167
+ self.user_id = os.getenv("WB_USER_ID", "") or claims["user_id"]
168
+ self.enterprise_id = os.getenv("WB_ENTERPRISE_ID", "") or claims["enterprise_id"]
169
+ self.domain = os.getenv("WB_DOMAIN", "") or claims["domain"]
170
+ log.info(f"User: {self.user_id[:8]}..., Enterprise: {self.enterprise_id}, Domain: {self.domain}")
171
+
172
+ def _load_from_file(self):
173
+ if TOKEN_FILE.exists():
174
+ try:
175
+ data = json.loads(TOKEN_FILE.read_text(encoding="utf-8"))
176
+ self.access_token = data.get("access_token", "")
177
+ self.refresh_token = data.get("refresh_token", "")
178
+ if self.access_token:
179
+ log.info("Token loaded from file")
180
+ except Exception:
181
+ pass
182
+
183
+ def _save_to_file(self):
184
+ TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
185
+ TOKEN_FILE.write_text(json.dumps({
186
+ "access_token": self.access_token,
187
+ "refresh_token": self.refresh_token,
188
+ "saved_at": time.strftime("%Y-%m-%d %H:%M:%S"),
189
+ }, indent=2), encoding="utf-8")
190
+
191
+ async def get_token(self) -> str:
192
+ if self._is_expired():
193
+ await self.refresh()
194
+ return self.access_token
195
+
196
+ def _is_expired(self) -> bool:
197
+ if not self.access_token:
198
+ return True
199
+ try:
200
+ payload = jwt.decode(self.access_token, options={"verify_signature": False})
201
+ return time.time() > (payload.get("exp", 0) - 300)
202
+ except Exception as e:
203
+ log.warning(f"Failed to decode token, treating as expired: {e}")
204
+ return True
205
+
206
+ def _log_token_info(self):
207
+ try:
208
+ payload = jwt.decode(self.access_token, options={"verify_signature": False})
209
+ hours = (payload.get("exp", 0) - time.time()) / 3600
210
+ log.info(f"Token valid, expires in {hours:.1f}h")
211
+ except Exception:
212
+ log.warning("Could not decode token")
213
+
214
+ async def refresh(self):
215
+ async with self._lock:
216
+ if not self._is_expired():
217
+ return
218
+ if self.refresh_token:
219
+ await self._refresh_via_api()
220
+ else:
221
+ await self._extract_from_cdp()
222
+
223
+ async def _refresh_via_api(self):
224
+ log.info("Refreshing token via API...")
225
+ headers = {
226
+ **HEADERS_TEMPLATE,
227
+ "Content-Type": "application/json",
228
+ "Authorization": f"Bearer {self.access_token}",
229
+ "X-Refresh-Token": self.refresh_token,
230
+ "X-Auth-Refresh-Source": "plugin",
231
+ "X-Domain": self.domain,
232
+ "X-User-Id": self.user_id,
233
+ "X-Enterprise-Id": self.enterprise_id,
234
+ "X-Tenant-Id": self.enterprise_id,
235
+ "X-Request-ID": uuid.uuid4().hex,
236
+ "X-Request-Trace-Id": str(uuid.uuid4()),
237
+ }
238
+ if self.department_info:
239
+ headers["X-Department-Info"] = self.department_info
240
+ async with httpx.AsyncClient() as client:
241
+ resp = await client.post(
242
+ f"{WB_API_BASE}/v2/plugin/auth/token/refresh",
243
+ headers=headers,
244
+ json={},
245
+ timeout=15,
246
+ )
247
+ data = resp.json()
248
+ if data.get("code") == 0 and data.get("data", {}).get("accessToken"):
249
+ self.access_token = data["data"]["accessToken"]
250
+ if data["data"].get("refreshToken"):
251
+ self.refresh_token = data["data"]["refreshToken"]
252
+ self._apply_claims()
253
+ log.info("Token refreshed successfully via API")
254
+ self._log_token_info()
255
+ self._save_to_file()
256
+ else:
257
+ log.error(f"Token refresh failed: {data}")
258
+ await self._extract_from_cdp()
259
+
260
+ async def _extract_from_cdp(self):
261
+ """Extract token via CDP (deprecated: WorkBuddy 5.1.4+ no longer exposes ipcRenderer)."""
262
+ log.info(f"Extracting token from WorkBuddy via CDP ({CDP_URL})...")
263
+ try:
264
+ async with httpx.AsyncClient() as client:
265
+ resp = await client.get(f"{CDP_URL}/json", timeout=5)
266
+ targets = resp.json()
267
+
268
+ ws_url = None
269
+ for t in targets:
270
+ if t.get("type") == "page" and "workbench" in t.get("url", ""):
271
+ ws_url = t.get("webSocketDebuggerUrl")
272
+ break
273
+ if not ws_url:
274
+ for t in targets:
275
+ if t.get("type") == "page":
276
+ ws_url = t.get("webSocketDebuggerUrl")
277
+ break
278
+ if not ws_url:
279
+ log.error("No CDP target found")
280
+ return
281
+
282
+ import websockets
283
+
284
+ async with websockets.connect(ws_url) as ws:
285
+ cmd = {
286
+ "id": 1,
287
+ "method": "Runtime.evaluate",
288
+ "params": {
289
+ "expression": """
290
+ (async () => {
291
+ try {
292
+ const s = await window.vscode.ipcRenderer.invoke(
293
+ 'vscode:genie:auth:getSession'
294
+ );
295
+ return JSON.stringify(s);
296
+ } catch(e) {
297
+ return JSON.stringify({error: e.message});
298
+ }
299
+ })()
300
+ """,
301
+ "awaitPromise": True,
302
+ "returnByValue": True,
303
+ },
304
+ }
305
+ await ws.send(json.dumps(cmd))
306
+ result = json.loads(await asyncio.wait_for(ws.recv(), timeout=10))
307
+
308
+ value = result.get("result", {}).get("result", {}).get("value", "")
309
+ if value:
310
+ session = json.loads(value)
311
+ auth = session.get("auth", session)
312
+ if auth.get("accessToken"):
313
+ self.access_token = auth["accessToken"]
314
+ self.refresh_token = auth.get("refreshToken", "")
315
+ account = session.get("account", {})
316
+ if isinstance(account, dict):
317
+ self.department_info = account.get("departmentFullName", "")
318
+ self._apply_claims()
319
+ log.info("Token extracted from CDP successfully")
320
+ self._log_token_info()
321
+ self._save_to_file()
322
+ elif session.get("error"):
323
+ log.error(f"CDP extraction error: {session['error']}")
324
+
325
+ except ImportError:
326
+ log.warning("websockets not installed — run: pip install websockets")
327
+ except Exception as e:
328
+ log.error(f"CDP extraction failed: {e}")
329
+
330
+
331
+ token_mgr = TokenManager()
332
+
333
+
334
+ # ---------------------------------------------------------------------------
335
+ # Cursor model name mapping
336
+ # Cursor validates model names server-side; only its built-in names pass.
337
+ # This map translates Cursor names → WorkBuddy model IDs.
338
+ # ---------------------------------------------------------------------------
339
+ CURSOR_TO_WB_MAP: dict[str, str] = {
340
+ # Claude
341
+ "claude-4.6-opus-high": "claude-opus-4.6",
342
+ "claude-4.6-opus-max": "claude-opus-4.6-1m",
343
+ "claude-4.6-opus-high-thinking": "claude-opus-4.6",
344
+ "claude-4.6-opus-high-thinking-fast": "claude-opus-4.6",
345
+ "claude-4.6-opus-max-thinking": "claude-opus-4.6-1m",
346
+ "claude-4.6-opus-max-thinking-fast": "claude-opus-4.6-1m",
347
+ "claude-4.6-sonnet-medium": "claude-sonnet-4.6",
348
+ "claude-4.6-sonnet-medium-thinking": "claude-sonnet-4.6-1m",
349
+ "claude-4.5-opus-high": "claude-opus-4.5",
350
+ "claude-4.5-opus-high-thinking": "claude-opus-4.5",
351
+ "claude-4.5-sonnet": "claude-4.5",
352
+ "claude-4.5-sonnet-thinking": "claude-4.5",
353
+ "claude-4.5-haiku": "claude-haiku-4.5",
354
+ "claude-4.5-haiku-thinking": "claude-haiku-4.5",
355
+ "claude-opus-4.6": "claude-opus-4.6",
356
+ # Gemini
357
+ "gemini-3.1-pro": "gemini-3.0-pro",
358
+ "gemini-3-flash": "gemini-3.1-flash-lite",
359
+ # Kimi
360
+ "kimi-k2.5": "kimi-k2.5-ioa",
361
+ }
362
+
363
+ # Reverse map: WB model ID → preferred Cursor alias (for /v1/models)
364
+ WB_TO_CURSOR_MAP: dict[str, str] = {
365
+ "claude-opus-4.6": "claude-4.6-opus-high",
366
+ "claude-opus-4.6-1m": "claude-4.6-opus-max",
367
+ "claude-sonnet-4.6": "claude-4.6-sonnet-medium",
368
+ "claude-sonnet-4.6-1m": "claude-4.6-sonnet-medium-thinking",
369
+ "claude-opus-4.5": "claude-4.5-opus-high",
370
+ "claude-4.5": "claude-4.5-sonnet",
371
+ "claude-haiku-4.5": "claude-4.5-haiku",
372
+ "gemini-3.0-pro": "gemini-3.1-pro",
373
+ "gemini-3.1-flash-lite": "gemini-3-flash",
374
+ "kimi-k2.5-ioa": "kimi-k2.5",
375
+ }
376
+
377
+
378
+ def resolve_model(model: str) -> str:
379
+ """Resolve Cursor model name to WorkBuddy model ID. Pass through if no mapping."""
380
+ return CURSOR_TO_WB_MAP.get(model, model)
381
+
382
+
383
+ # ---------------------------------------------------------------------------
384
+ # Available models
385
+ # ---------------------------------------------------------------------------
386
+ MODELS = [
387
+ # DeepSeek
388
+ {"id": "deepseek-r1", "name": "DeepSeek-R1"},
389
+ {"id": "deepseek-r1-0528-lkeap", "name": "DeepSeek-R1-LKEAP"},
390
+ {"id": "deepseek-v3", "name": "DeepSeek-V3"},
391
+ {"id": "deepseek-v3.2", "name": "DeepSeek-V3.2"},
392
+ {"id": "deepseek-v3-1", "name": "DeepSeek-V3.1"},
393
+ {"id": "deepseek-v3-0324", "name": "DeepSeek-V3-0324"},
394
+ {"id": "deepseek-v3-0324-lkeap", "name": "DeepSeek-V3-0324-LKEAP"},
395
+ {"id": "deepseek-v3-1-volc", "name": "DeepSeek-V3.1-Volc"},
396
+ {"id": "deepseek-v4-pro", "name": "DeepSeek-V4-Pro"},
397
+ {"id": "deepseek-v4-flash", "name": "DeepSeek-V4-Flash"},
398
+ # Claude (haiku only — opus/sonnet require enterprise auth)
399
+ {"id": "claude-haiku-4.5", "name": "Claude-Haiku-4.5"},
400
+ {"id": "claude-4.5-haiku", "name": "Claude-4.5-Haiku"},
401
+ {"id": "claude-4.5-haiku-thinking", "name": "Claude-4.5-Haiku-Thinking"},
402
+ # GLM
403
+ {"id": "glm-4.6", "name": "GLM-4.6"},
404
+ {"id": "glm-4.7", "name": "GLM-4.7"},
405
+ {"id": "glm-5.0", "name": "GLM-5.0"},
406
+ {"id": "glm-5.1", "name": "GLM-5.1"},
407
+ {"id": "glm-5.2", "name": "GLM-5.2"},
408
+ {"id": "glm-5v-turbo", "name": "GLM-5v-Turbo"},
409
+ {"id": "glm-5v-turbo-ioa", "name": "GLM-5v-Turbo-IOA"},
410
+ # Hunyuan
411
+ {"id": "hunyuan-2.0-instruct", "name": "Hunyuan-2.0"},
412
+ {"id": "hunyuan-2.0-thinking", "name": "Hunyuan-2.0-Thinking"},
413
+ {"id": "hunyuan-2.0-thinking-ioa", "name": "Hunyuan-2.0-Thinking-IOA"},
414
+ {"id": "hy3-preview", "name": "Hunyuan-3-Preview"},
415
+ # Kimi
416
+ {"id": "kimi-k2.6", "name": "Kimi-K2.6"},
417
+ {"id": "kimi-k2.6-ioa", "name": "Kimi-K2.6-IOA"},
418
+ {"id": "kimi-k2.7", "name": "Kimi-K2.7"},
419
+ # MiniMax
420
+ {"id": "minimax-m3", "name": "MiniMax-M3"},
421
+ {"id": "minimax-m3-ioa", "name": "MiniMax-M3-IOA"},
422
+ # Default
423
+ {"id": "codewise-default-model-v2", "name": "Default (Codewise)"},
424
+ ]
425
+
426
+
427
+ # ---------------------------------------------------------------------------
428
+ # FastAPI app
429
+ # ---------------------------------------------------------------------------
430
+ http_pool: httpx.AsyncClient | None = None
431
+
432
+
433
+ @asynccontextmanager
434
+ async def lifespan(_app: FastAPI):
435
+ global http_pool
436
+ http_pool = httpx.AsyncClient(
437
+ timeout=httpx.Timeout(DEFAULT_TIMEOUT, connect=10),
438
+ limits=httpx.Limits(max_connections=50, max_keepalive_connections=10),
439
+ )
440
+ await token_mgr.init()
441
+ yield
442
+ await http_pool.aclose()
443
+ http_pool = None
444
+
445
+
446
+ app = FastAPI(title="WorkBuddy Proxy", lifespan=lifespan)
447
+
448
+ # CORS: allow all origins since this is a public-facing API proxy.
449
+ # Auth is enforced via API key, not origin.
450
+ app.add_middleware(
451
+ CORSMiddleware,
452
+ allow_origins=["*"],
453
+ allow_credentials=False, # 与 allow_origins=["*"] 不能同时为 True
454
+ allow_methods=["*"],
455
+ allow_headers=["*"],
456
+ )
457
+
458
+
459
+ def _verify_api_key(request: Request):
460
+ auth = request.headers.get("Authorization") or ""
461
+ key = auth.replace("Bearer ", "").strip()
462
+ if not key:
463
+ key = (request.headers.get("X-API-Key") or "").strip()
464
+ if not hmac.compare_digest(key, PROXY_API_KEY):
465
+ raise HTTPException(status_code=401, detail="Invalid API key")
466
+
467
+
468
+ def _build_headers(access_token: str) -> dict:
469
+ headers = {
470
+ **HEADERS_TEMPLATE,
471
+ "Content-Type": "application/json",
472
+ "Accept": "text/event-stream",
473
+ "Authorization": f"Bearer {access_token}",
474
+ "X-User-Id": token_mgr.user_id,
475
+ "X-Enterprise-Id": token_mgr.enterprise_id,
476
+ "X-Tenant-Id": token_mgr.enterprise_id,
477
+ "X-Domain": token_mgr.domain,
478
+ "X-Request-ID": uuid.uuid4().hex,
479
+ "X-Request-Trace-Id": str(uuid.uuid4()),
480
+ }
481
+ if token_mgr.department_info:
482
+ headers["X-Department-Info"] = token_mgr.department_info
483
+ return headers
484
+
485
+
486
+ @app.get("/v1/models")
487
+ async def list_models(request: Request):
488
+ _verify_api_key(request)
489
+
490
+ # Build model list: original WB models + Cursor-compatible aliases
491
+ seen_ids: set[str] = set()
492
+ data = []
493
+
494
+ # First: add Cursor-compatible aliases for mapped models
495
+ for cursor_name, wb_id in CURSOR_TO_WB_MAP.items():
496
+ if cursor_name not in seen_ids:
497
+ seen_ids.add(cursor_name)
498
+ # Find display name from MODELS list
499
+ wb_model = next((m for m in MODELS if m["id"] == wb_id), None)
500
+ display_name = wb_model["name"] if wb_model else cursor_name
501
+ data.append({
502
+ "id": cursor_name,
503
+ "object": "model",
504
+ "created": 1700000000,
505
+ "owned_by": "workbuddy",
506
+ "name": f"{display_name} (Cursor)",
507
+ })
508
+
509
+ # Then: add original WB models (for non-Cursor clients like OpenClaw)
510
+ for m in MODELS:
511
+ if m["id"] not in seen_ids:
512
+ seen_ids.add(m["id"])
513
+ data.append({
514
+ "id": m["id"],
515
+ "object": "model",
516
+ "created": 1700000000,
517
+ "owned_by": "workbuddy",
518
+ "name": m["name"],
519
+ })
520
+
521
+ return {"object": "list", "data": data}
522
+
523
+
524
+ def _timeout_for(model: str) -> float:
525
+ return REASONING_TIMEOUT if model in REASONING_MODELS else DEFAULT_TIMEOUT
526
+
527
+
528
+ async def _upstream_stream(url: str, headers: dict, body: dict, timeout: float):
529
+ """Open a streaming connection to upstream; returns resp or None on failure."""
530
+ try:
531
+ req = http_pool.build_request("POST", url, headers=headers, json=body, timeout=timeout)
532
+ resp = await http_pool.send(req, stream=True)
533
+ return resp
534
+ except (httpx.TimeoutException, httpx.ConnectError, httpx.NetworkError, httpx.RemoteProtocolError):
535
+ return None
536
+
537
+
538
+ @app.post("/v1/chat/completions")
539
+ async def chat_completions(request: Request):
540
+ _verify_api_key(request)
541
+
542
+ body = await request.json()
543
+ raw_model = body.get("model", "deepseek-v3")
544
+ model = resolve_model(raw_model) # Cursor name → WB model ID
545
+ stream = body.get("stream", False)
546
+
547
+ if raw_model != model:
548
+ log.info(f"[Model] Mapped: {raw_model} → {model}")
549
+
550
+ wb_body = {k: v for k, v in body.items() if k != "stream"}
551
+ wb_body["stream"] = True
552
+ wb_body["model"] = model
553
+
554
+ # Auto-inject reasoning for thinking models
555
+ if model in REASONING_MODELS:
556
+ wb_body.setdefault("reasoning", {"effort": "medium"})
557
+
558
+ access_token = await token_mgr.get_token()
559
+ if not access_token:
560
+ raise HTTPException(status_code=503, detail="No valid WorkBuddy token")
561
+
562
+ url = f"{WB_API_BASE}/v2/chat/completions"
563
+ timeout = _timeout_for(model)
564
+ t_start = time.monotonic()
565
+
566
+ if stream:
567
+ return StreamingResponse(
568
+ _stream_response(url, wb_body, model, timeout),
569
+ media_type="text/event-stream",
570
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
571
+ )
572
+ return await _non_stream_response(url, wb_body, model, timeout, t_start)
573
+
574
+
575
+ # ---------------------------------------------------------------------------
576
+ # Streaming path
577
+ # ---------------------------------------------------------------------------
578
+ async def _stream_response(
579
+ url: str, body: dict, model: str, timeout: float
580
+ ) -> AsyncGenerator[str, None]:
581
+ max_attempts = 2
582
+ for attempt in range(1, max_attempts + 1):
583
+ access_token = await token_mgr.get_token()
584
+ headers = _build_headers(access_token)
585
+ t_start = time.monotonic()
586
+ has_content = False
587
+
588
+ resp = await _upstream_stream(url, headers, body, timeout)
589
+ if resp is None:
590
+ log.error(f"[{model}] Upstream timeout (attempt {attempt})")
591
+ if attempt < max_attempts:
592
+ continue
593
+ yield 'data: {"error":"upstream timeout"}\n\n'
594
+ yield "data: [DONE]\n\n"
595
+ return
596
+
597
+ try:
598
+ if resp.status_code == 401:
599
+ log.warning(f"[{model}] Got 401, refreshing token...")
600
+ await token_mgr.refresh()
601
+ if attempt < max_attempts:
602
+ continue
603
+ yield 'data: {"error":"authentication failed"}\n\n'
604
+ yield "data: [DONE]\n\n"
605
+ return
606
+
607
+ if resp.status_code != 200:
608
+ error_body = await resp.aread()
609
+ error_text = error_body.decode("utf-8", errors="replace")
610
+ log.error(f"[{model}] Upstream {resp.status_code}: {error_text[:200]}")
611
+ yield f"data: {json.dumps({'error': error_text})}\n\n"
612
+ yield "data: [DONE]\n\n"
613
+ return
614
+
615
+ done_sent = False
616
+ reasoning_buf = ""
617
+ async for line in resp.aiter_lines():
618
+ if not line.startswith("data: "):
619
+ if line.strip():
620
+ has_content = True
621
+ yield line + "\n\n"
622
+ continue
623
+ if line == "data: [DONE]":
624
+ done_sent = True
625
+ if reasoning_buf:
626
+ buf_chunk = {"choices": [{"index": 0, "delta": {"reasoning_content": reasoning_buf}}]}
627
+ yield f"data: {json.dumps(buf_chunk)}\n\n"
628
+ yield line + "\n\n"
629
+ continue
630
+
631
+ has_content = True
632
+ try:
633
+ chunk = json.loads(line.removeprefix("data: "))
634
+ choice = chunk.get("choices", [{}])[0]
635
+ delta = choice.get("delta", {})
636
+ rc = delta.get("reasoning_content")
637
+ has_content_field = bool(delta.get("content") or delta.get("tool_calls"))
638
+
639
+ if rc is not None:
640
+ cleaned = _clean_reasoning(rc)
641
+ if has_content_field and reasoning_buf:
642
+ buf_chunk = {"choices": [{"index": 0, "delta": {"reasoning_content": reasoning_buf}}]}
643
+ yield f"data: {json.dumps(buf_chunk)}\n\n"
644
+ reasoning_buf = ""
645
+ reasoning_buf += cleaned
646
+ if not has_content_field:
647
+ continue
648
+ delta.pop("reasoning_content", None)
649
+
650
+ if reasoning_buf:
651
+ buf_chunk = {"choices": [{"index": 0, "delta": {"reasoning_content": reasoning_buf}}]}
652
+ yield f"data: {json.dumps(buf_chunk)}\n\n"
653
+ reasoning_buf = ""
654
+
655
+ yield f"data: {json.dumps(chunk)}\n\n"
656
+ except (json.JSONDecodeError, KeyError):
657
+ yield line + "\n\n"
658
+
659
+ elapsed = time.monotonic() - t_start
660
+
661
+ if not has_content and not reasoning_buf and attempt < max_attempts:
662
+ log.warning(f"[{model}] Empty response, retrying... ({elapsed:.1f}s)")
663
+ await asyncio.sleep(1)
664
+ continue
665
+
666
+ if not done_sent:
667
+ yield "data: [DONE]\n\n"
668
+
669
+ log.info(f"[{model}] stream {elapsed:.1f}s")
670
+ return
671
+
672
+ except httpx.ReadTimeout:
673
+ log.error(f"[{model}] Read timeout during stream (attempt {attempt})")
674
+ if attempt < max_attempts:
675
+ continue
676
+ yield 'data: {"error":"upstream timeout"}\n\n'
677
+ yield "data: [DONE]\n\n"
678
+ return
679
+ finally:
680
+ await resp.aclose()
681
+
682
+
683
+ # ---------------------------------------------------------------------------
684
+ # Non-streaming path
685
+ # ---------------------------------------------------------------------------
686
+ async def _non_stream_response(
687
+ url: str, body: dict, model: str, timeout: float, t_start: float
688
+ ) -> JSONResponse:
689
+ max_attempts = 2
690
+
691
+ for attempt in range(1, max_attempts + 1):
692
+ access_token = await token_mgr.get_token()
693
+ headers = _build_headers(access_token)
694
+
695
+ collected_content = ""
696
+ collected_reasoning = ""
697
+ tool_calls_map: dict[int, dict] = {}
698
+ finish_reason = "stop"
699
+ resp_model = model
700
+ usage = {}
701
+
702
+ resp = await _upstream_stream(url, headers, body, timeout)
703
+ if resp is None:
704
+ log.error(f"[{model}] Upstream timeout (attempt {attempt})")
705
+ if attempt < max_attempts:
706
+ continue
707
+ raise HTTPException(status_code=504, detail="Upstream timeout")
708
+
709
+ try:
710
+ if resp.status_code == 401:
711
+ log.warning(f"[{model}] Got 401, refreshing token...")
712
+ await token_mgr.refresh()
713
+ if attempt < max_attempts:
714
+ continue
715
+ raise HTTPException(status_code=401, detail="Authentication failed")
716
+
717
+ if resp.status_code != 200:
718
+ error_body = await resp.aread()
719
+ raise HTTPException(status_code=resp.status_code,
720
+ detail=error_body.decode("utf-8", errors="replace"))
721
+
722
+ async for line in resp.aiter_lines():
723
+ text = line.removeprefix("data: ").strip()
724
+ if not text or text == "[DONE]":
725
+ continue
726
+ try:
727
+ chunk = json.loads(text)
728
+ choice = chunk.get("choices", [{}])[0]
729
+ delta = choice.get("delta", {})
730
+
731
+ collected_content += delta.get("content") or ""
732
+ rc = delta.get("reasoning_content")
733
+ if rc:
734
+ collected_reasoning += _clean_reasoning(rc)
735
+
736
+ for tc in delta.get("tool_calls") or []:
737
+ idx = tc.get("index", 0)
738
+ if idx not in tool_calls_map:
739
+ tool_calls_map[idx] = {
740
+ "id": tc.get("id", ""),
741
+ "type": "function",
742
+ "function": {"name": "", "arguments": ""},
743
+ }
744
+ entry = tool_calls_map[idx]
745
+ if tc.get("id"):
746
+ entry["id"] = tc["id"]
747
+ fn = tc.get("function", {})
748
+ if fn.get("name"):
749
+ entry["function"]["name"] += fn["name"]
750
+ if fn.get("arguments"):
751
+ entry["function"]["arguments"] += fn["arguments"]
752
+
753
+ fr = choice.get("finish_reason")
754
+ if fr:
755
+ finish_reason = fr
756
+
757
+ if chunk.get("usage"):
758
+ usage = chunk["usage"]
759
+ resp_model = chunk.get("model", resp_model)
760
+ except (json.JSONDecodeError, IndexError, KeyError):
761
+ pass
762
+
763
+ except httpx.ReadTimeout:
764
+ log.error(f"[{model}] Read timeout during non-stream (attempt {attempt})")
765
+ if attempt < max_attempts:
766
+ continue
767
+ raise HTTPException(status_code=504, detail="Upstream read timeout")
768
+ finally:
769
+ await resp.aclose()
770
+
771
+ if not collected_content and not collected_reasoning and not tool_calls_map and attempt < max_attempts:
772
+ log.warning(f"[{model}] Empty response, retrying...")
773
+ await asyncio.sleep(1)
774
+ continue
775
+
776
+ elapsed = time.monotonic() - t_start
777
+ prompt_t = usage.get("prompt_tokens", "?")
778
+ compl_t = usage.get("completion_tokens", "?")
779
+ log.info(f"[{model}] non-stream {elapsed:.1f}s prompt={prompt_t} completion={compl_t}")
780
+
781
+ message: dict = {"role": "assistant", "content": collected_content or None}
782
+ if collected_reasoning:
783
+ message["reasoning_content"] = collected_reasoning
784
+ if tool_calls_map:
785
+ message["tool_calls"] = [tool_calls_map[i] for i in sorted(tool_calls_map)]
786
+
787
+ return JSONResponse({
788
+ "id": f"chatcmpl-{uuid.uuid4().hex[:12]}",
789
+ "object": "chat.completion",
790
+ "created": int(time.time()),
791
+ "model": resp_model,
792
+ "choices": [{
793
+ "index": 0,
794
+ "message": message,
795
+ "finish_reason": finish_reason,
796
+ }],
797
+ "usage": usage,
798
+ })
799
+
800
+ raise HTTPException(status_code=502, detail="Upstream returned empty response")
801
+
802
+
803
+ @app.get("/health")
804
+ async def health():
805
+ has_token = bool(token_mgr.access_token)
806
+ expired = token_mgr._is_expired()
807
+ return {"status": "ok" if has_token and not expired else "degraded",
808
+ "has_token": has_token, "expired": expired}
809
+
810
+
811
+ if __name__ == "__main__":
812
+ if not PROXY_API_KEY:
813
+ log.error("PROXY_API_KEY is not set. Set it via env var or .env file.")
814
+ log.error("Example: PROXY_API_KEY=$(openssl rand -hex 32) python server.py")
815
+ sys.exit(1)
816
+ if len(PROXY_API_KEY) < 16:
817
+ log.warning("PROXY_API_KEY is shorter than 16 chars — consider using a stronger key")
818
+ log.info(f"Starting WorkBuddy proxy on port {PROXY_PORT}")
819
+ log.info(f"WB version: {WB_VERSION}")
820
+ log.info(f"API key: {PROXY_API_KEY[:4]}***{PROXY_API_KEY[-4:] if len(PROXY_API_KEY) > 8 else ''}")
821
+ log.info(f"Upstream: {WB_API_BASE}")
822
+ uvicorn.run(app, host="0.0.0.0", port=PROXY_PORT, log_level="info")