magpie-gemini-web 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -98
- package/index.mjs +77 -4
- package/lib/backend.mjs +6 -1
- package/lib/native.mjs +219 -43
- package/lib/setup.mjs +129 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,125 +4,88 @@ Gemini Web provider plugin for [Magpie](https://usemagpie.ai/)(遵循 OpenCode
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
## 1.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
|
|
15
|
-
2.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
3.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
- **Node.js**:版本 `>= 22`(用于执行安装与管理脚本 `npm run backend:*` 及单元测试)。
|
|
28
|
-
- **Magpie / Bun**:Magpie 客户端内置管理其插件运行环境(基于 Bun 执行插件),无需全局安装 Bun。
|
|
29
|
-
- **操作系统支持**:
|
|
30
|
-
- 本项目的自动安装与启动助手(`npm run backend:install` / `backend:start`)**仅针对 macOS (arm64 Apple Silicon)** 进行了二进制锁定与 SHA-256 完整性强校验。
|
|
31
|
-
- **其他操作系统(Linux / Windows / Intel Mac)**:请**不要**将其他平台二进制放入助手目录。请直接参考上游 [gemini-web2api-go 文档](https://github.com/zexadev/gemini-web2api-go) 手动运行上游服务,并在 Magpie 登录插件时将 `baseURL` 指向上游监听地址即可。
|
|
32
|
-
|
|
33
|
-
---
|
|
34
|
-
|
|
35
|
-
## 3. 快速上手
|
|
36
|
-
|
|
37
|
-
插件与后端需要分别安装。Magpie 安装插件时不会执行安装脚本,也不会自动启动 Go 后端。先获取管理脚本:
|
|
7
|
+
## 1. 快速上手(推荐:一键配置)
|
|
8
|
+
|
|
9
|
+
1. 在 Magpie 插件市场搜索并安装 **magpie-gemini-web**。
|
|
10
|
+
2. 在 Magpie 里登录 Gemini Web 提供商,选择 **「一键配置本地 Gemini Web(推荐)」**(命令行为 `magpie plugin login gemini-web`)。插件会:
|
|
11
|
+
- 下载官方 [gemini-web2api-go](https://github.com/zexadev/gemini-web2api-go) v4.22.0 后端,并按发布页的 SHA-256 校验,校验不通过就不安装;
|
|
12
|
+
- 生成本地随机密钥,在后台启动后端(只监听 `127.0.0.1:8083`);
|
|
13
|
+
- 打开 <https://gemini.google.com/app>,提示你复制 Cookie:
|
|
14
|
+
1. 确认网页已登录要使用的 Google 账号;
|
|
15
|
+
2. 按 F12(macOS 按 ⌥⌘I)打开开发者工具,切到 **Network(网络)**,刷新页面;
|
|
16
|
+
3. 点任意一个 `gemini.google.com` 的请求 → **Headers(标头)** → **Request Headers(请求标头)**;
|
|
17
|
+
4. 复制 `cookie` 那一行的值,粘贴回 Magpie。
|
|
18
|
+
3. 插件把 Cookie 导入本地后端并检测能否登录 Gemini;检测通过即完成登录,检测失败会删掉这条账号并提示原因(例如缺少哪些 Cookie、账号未登录)。全程不需要手动填写 API key。
|
|
19
|
+
|
|
20
|
+
之后每次 Magpie 启动,插件发现本地后端没在运行就会自动把它拉起来;请求时发现后端掉线也会先重启再重试一次。后端日志在数据目录下的 `backend.log`。
|
|
21
|
+
|
|
22
|
+
> 要再加一个 Google 账号,重新走一遍一键配置即可,新账号会加入后端的账号池。
|
|
23
|
+
>
|
|
24
|
+
> Cookie 只发给本机的后端,保存在它的本地 SQLite 数据库里;Magpie 保存的是后端的本地 API key,而不是 Cookie。
|
|
25
|
+
|
|
26
|
+
需要工具调用或图片输入时再开启插件选项(默认关闭,见第 4 节):
|
|
38
27
|
```bash
|
|
39
|
-
|
|
40
|
-
cd magpie-gemini-web
|
|
28
|
+
magpie plugin options magpie-gemini-web '{"allowTools":true,"allowImages":true}'
|
|
41
29
|
```
|
|
42
|
-
官方后端自 v4.22.0 起已包含 `gemini-4-argon` / `gemini-4-argon-thinking`,但不核对上游实际返回的模型:无权限、Cookie 失效或配额耗尽时会被静默降级成 3.5 Flash-Lite。需要身份校验与延迟优化时,请直接按第 5 节构建补丁版后端,再完成 Cookie 配置和插件登录;不要先启动官方后端占用同一端口。
|
|
43
30
|
|
|
44
|
-
|
|
31
|
+
**支持的平台**:一键配置为以下平台锁定了官方二进制及其 SHA-256:macOS(Apple Silicon / Intel)、Linux(x64 / arm64)、Windows(x64 / arm64)。其他平台请用第 3 节的「连接已有后端」。
|
|
45
32
|
|
|
46
|
-
|
|
33
|
+
---
|
|
47
34
|
|
|
48
|
-
|
|
49
|
-
npm run backend:install
|
|
50
|
-
```
|
|
35
|
+
## 2. 架构与安全说明
|
|
51
36
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
37
|
+
1. **架构解耦**:
|
|
38
|
+
- **Magpie 插件层**(本仓库):纯 JavaScript (ESM),无第三方依赖。负责认证与一键配置、动态模型目录 (`models`)、请求转发与输入过滤 (`forward`),以及管理本地后端进程。
|
|
39
|
+
- **原生后端层**(`gemini-web2api-go`):上游独立 Go 进程,维护 Google 账号 Cookie 池并提供 OpenAI 兼容接口。
|
|
40
|
+
2. **凭证隔离**:一键配置时插件只把你粘贴的 Cookie 交给本机后端的管理接口,自身不保存、不转发到其他地方;之后插件与 Magpie 仅凭本地随机 `apiKey` 访问后端。
|
|
41
|
+
3. **本地回环**:上游默认监听 `0.0.0.0`;本插件生成的配置**锁定为 `127.0.0.1:8083`**,启动前还会再次校验 host 与二进制哈希。若 8083 已被别的服务占用,一键配置会报错而不会覆盖,可修改数据目录下 `config.json` 的 `port`。
|
|
42
|
+
4. **请求过滤**:转发前剥离调用方的 `Cookie`、`Authorization`、`x-api-key`、`anthropic-*` 等头部。
|
|
43
|
+
5. **并发抑制**:插件声明 `maxConcurrency: 1`,降低请求频率,但**不能保证账号不被 Google 风控或封禁**;请知悉使用网页逆向服务的风险。
|
|
44
|
+
6. **开源协议**:上游项目与本插件均为 MIT 许可证。
|
|
56
45
|
|
|
57
|
-
|
|
46
|
+
**数据目录**:默认 `~/.local/share/magpie-gemini-web`(可用环境变量 `GEMINI_WEB_DATA_DIR` 自定义),包含:
|
|
58
47
|
|
|
59
|
-
|
|
|
48
|
+
| 文件 | 内容 |
|
|
60
49
|
| :--- | :--- |
|
|
61
|
-
| `
|
|
62
|
-
| `
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
### 步骤 2:启动后端服务
|
|
50
|
+
| `bin/gemini-web2api-go` | 后端二进制(权限 `0700`) |
|
|
51
|
+
| `config.json` | 后端配置,host 固定 `127.0.0.1`(权限 `0600`) |
|
|
52
|
+
| `secrets.json` | 本地随机生成的 `apiKey`(`gw_` 开头,插件访问后端用)和 `adminToken`(管理面板 `http://127.0.0.1:8083/admin` 的口令),均与 Google 账号无关(权限 `0600`) |
|
|
53
|
+
| `gemini.db` | 后端的账号与请求记录 |
|
|
54
|
+
| `backend.log` / `backend.pid` | 插件在后台启动的后端的日志与进程号 |
|
|
67
55
|
|
|
56
|
+
只要不删除 `secrets.json`,重新配置不会更换这两个值。要查看或管理已导入的账号,可打开管理面板,用 `adminToken` 登录;把它复制到剪贴板(macOS,不在终端显示):
|
|
68
57
|
```bash
|
|
69
|
-
|
|
58
|
+
node -p "require(require('path').join(process.env.GEMINI_WEB_DATA_DIR || require('os').homedir() + '/.local/share/magpie-gemini-web', 'secrets.json')).adminToken" | tr -d '\n' | pbcopy
|
|
70
59
|
```
|
|
71
60
|
|
|
72
|
-
|
|
73
|
-
- 可在另一终端执行以下命令确认后端已监听并响应:
|
|
74
|
-
```bash
|
|
75
|
-
npm run backend:status
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
> 后端成功启动并列出基础模型目录,只代表本地服务通路正常,不代表全部能力可用。2026-10-14 已通过真实 Magpie 网关验证匿名 Flash Lite 普通对话;工具调用在非流式、流式和直连后端对照中均未成功,`tool_choice` 未得到满足。上游未返回缓存 token 统计,Magpie 显示的 0 不代表 Google 的实际缓存命中情况。登录态模型需要有效 Cookie。后续 Argon 补丁已验证真实 Magpie 普通对话、SSE 和首轮工具调用;工具结果回传含正确值但未严格遵循仅回显要求,图片和 Argon 缓存未验证。本地验收记录不随公开仓库或 npm 包分发。
|
|
79
|
-
|
|
80
|
-
### 步骤 3:在后端管理后台配置 Google Cookie
|
|
61
|
+
---
|
|
81
62
|
|
|
82
|
-
|
|
83
|
-
```
|
|
84
|
-
http://127.0.0.1:8083/admin
|
|
85
|
-
```
|
|
86
|
-
- 输入 `secrets.json` 中的 `adminToken` 登录管理面板。可用以下命令把它复制到剪贴板(macOS,不在终端显示):
|
|
87
|
-
```bash
|
|
88
|
-
node -p "require(require('path').join(process.env.GEMINI_WEB_DATA_DIR || require('os').homedir() + '/.local/share/magpie-gemini-web', 'secrets.json')).adminToken" | tr -d '\n' | pbcopy
|
|
89
|
-
```
|
|
90
|
-
- 在 Cookie 管理页面导入 Gemini 网页请求的完整 Cookie header;上游文档列出的关键项包括 `SID`、`HSID`、`SSID`、`APISID`、`SAPISID`、`__Secure-1PSID`、`__Secure-1PSIDTS`。仅有两个 `__Secure-*` 值未必足够。
|
|
91
|
-
- **注意**:Cookie 仅保存在后端的本地 SQLite 数据库中,插件绝不存储或转发 Cookie。
|
|
63
|
+
## 3. 连接已有后端(自定义地址 + API key)
|
|
92
64
|
|
|
93
|
-
|
|
65
|
+
已经自己运行了 gemini-web2api-go(其他机器、反向代理或自定义端口)时,登录时选 **「连接已有后端(自定义地址 + API key)」**:
|
|
94
66
|
|
|
95
|
-
1
|
|
96
|
-
|
|
97
|
-
magpie plugin add magpie-gemini-web
|
|
98
|
-
```
|
|
99
|
-
> 尚未发布到 npm 或从源码调试时可用 `magpie plugin add github:BUKOWSKIREAL/magpie-gemini-web` 或 `magpie plugin add /absolute/path/to/magpie-gemini-web`。
|
|
67
|
+
- **Backend OpenAI base URL**:留空使用默认的 `http://127.0.0.1:8083/v1`,或填入你的地址(必须以 `/v1` 结尾;本地只允许 loopback HTTP,远程须 HTTPS)。
|
|
68
|
+
- **API key**:后端的 `API_KEY`(不是 Google Cookie)。
|
|
100
69
|
|
|
101
|
-
|
|
102
|
-
```bash
|
|
103
|
-
magpie plugin login gemini-web
|
|
104
|
-
```
|
|
105
|
-
- **Backend OpenAI base URL**:留空使用默认的 `http://127.0.0.1:8083/v1`,或填入其他自定义本地/代理地址。
|
|
106
|
-
- **Gemini Web backend API key**:填入 `secrets.json` 中的 `apiKey`(不是 Google Cookie)。可先用以下命令把它复制到剪贴板(macOS,不在终端显示),登录时直接粘贴:
|
|
107
|
-
```bash
|
|
108
|
-
node -p "require(require('path').join(process.env.GEMINI_WEB_DATA_DIR || require('os').homedir() + '/.local/share/magpie-gemini-web', 'secrets.json')).apiKey" | tr -d '\n' | pbcopy
|
|
109
|
-
```
|
|
110
|
-
其他系统去掉末尾的 `| tr -d '\n' | pbcopy` 即可直接输出。
|
|
70
|
+
这种方式下插件不会安装或启动任何后端,Cookie 也需要你自己在后端管理面板导入(关键项包括 `SID`、`HSID`、`SSID`、`APISID`、`SAPISID`、`__Secure-1PSID`、`__Secure-1PSIDTS`,仅有两个 `__Secure-*` 值未必足够)。
|
|
111
71
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
72
|
+
> 重新登录(例如更换 API key 或 base URL)后,正在运行的 Magpie 不会重新读取 provider 地址,需要执行 `magpie plugin off magpie-gemini-web && magpie plugin on magpie-gemini-web` 或重启 Magpie App 才会生效。
|
|
73
|
+
>
|
|
74
|
+
> 0.1.0 登录时若留空 base URL,会把空字符串存进登录信息;0.1.1 起改为存入补全后的默认地址。若遇到网关返回 502「Gemini Web has no endpoint configured」([#3](https://github.com/BUKOWSKIREAL/magpie-gemini-web/issues/3)),升级后重新登录一次,或登录时显式填写 `http://127.0.0.1:8083/v1`。
|
|
115
75
|
|
|
116
|
-
|
|
117
|
-
插件默认关闭工具调用与图片输入以确保安全和兼容性。若 Coding Agent(如 Claude Code / OpenCode / Cursor)需要使用工具调用,**必须显式开启 `allowTools`**,否则发送工具请求时将被插件直接返回 400 拦截:
|
|
76
|
+
### 命令行管理脚本(开发者)
|
|
118
77
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
78
|
+
从源码仓库也可以手动管理同一个数据目录里的后端(需要 Node.js >= 22):
|
|
79
|
+
```bash
|
|
80
|
+
git clone https://github.com/BUKOWSKIREAL/magpie-gemini-web.git
|
|
81
|
+
cd magpie-gemini-web
|
|
82
|
+
npm run backend:install # 下载并校验 v4.22.0 二进制,创建 config.json / secrets.json
|
|
83
|
+
npm run backend:start # 前台运行后端
|
|
84
|
+
npm run backend:status # 检查后端是否响应
|
|
85
|
+
```
|
|
86
|
+
用这些脚本装好的后端,一键配置会直接复用(同一套密钥),不会重复下载。
|
|
122
87
|
|
|
123
|
-
|
|
124
|
-
magpie plugin options magpie-gemini-web '{"allowTools":false,"allowImages":false}'
|
|
125
|
-
```
|
|
88
|
+
> 后端成功启动并列出基础模型目录,只代表本地服务通路正常,不代表全部能力可用。2026-10-14 已通过真实 Magpie 网关验证匿名 Flash Lite 普通对话;工具调用在非流式、流式和直连后端对照中均未成功,`tool_choice` 未得到满足。上游未返回缓存 token 统计,Magpie 显示的 0 不代表 Google 的实际缓存命中情况。登录态模型需要有效 Cookie。后续 Argon 补丁已验证真实 Magpie 普通对话、SSE 和首轮工具调用;工具结果回传含正确值但未严格遵循仅回显要求,图片和 Argon 缓存未验证。本地验收记录不随公开仓库或 npm 包分发。
|
|
126
89
|
|
|
127
90
|
---
|
|
128
91
|
|
|
@@ -209,6 +172,8 @@ git -C backend-source apply ../patches/argon.patch
|
|
|
209
172
|
npm run backend:start:argon
|
|
210
173
|
```
|
|
211
174
|
|
|
175
|
+
装好后也可以不手动启动:插件认可已安装 Argon 版的哈希,Magpie 启动时会像官方版一样在后台拉起它。若插件已在后台运行旧版后端,先结束它(`kill $(cat ~/.local/share/magpie-gemini-web/backend.pid)`)再启动,否则端口会被占用。
|
|
176
|
+
|
|
212
177
|
> **版本切换提示**:如果重新执行官方标准的 `npm run backend:install`,将自动下载官方原版二进制并覆盖当前版本:Argon 模型仍可用,但不再有身份校验与延迟优化;重新执行 `npm run backend:install:argon` 即可切回 Argon 版本。
|
|
213
178
|
|
|
214
179
|
---
|
package/index.mjs
CHANGED
|
@@ -1,9 +1,54 @@
|
|
|
1
|
-
import { normalizeBaseURL, settings, credentials, listModels, forward } from './lib/backend.mjs';
|
|
1
|
+
import { normalizeBaseURL, settings, credentials, listModels, forward, UNREACHABLE_HEADER } from './lib/backend.mjs';
|
|
2
|
+
import { ensureBackend, localBackend } from './lib/native.mjs';
|
|
3
|
+
import { parseCookieInput, importCookie } from './lib/setup.mjs';
|
|
2
4
|
|
|
3
5
|
const ID = 'gemini-web';
|
|
6
|
+
const GEMINI_URL = 'https://gemini.google.com/app';
|
|
7
|
+
|
|
8
|
+
const COOKIE_STEPS = [
|
|
9
|
+
`在浏览器打开 ${GEMINI_URL},确认已登录要使用的 Google 账号,然后:`,
|
|
10
|
+
'1. 按 F12(macOS 按 ⌥⌘I)打开开发者工具,切到 Network(网络)面板,刷新页面',
|
|
11
|
+
'2. 点列表里任意一个 gemini.google.com 的请求 → Headers(标头)→ Request Headers(请求标头)',
|
|
12
|
+
'3. 找到 cookie 那一行,复制它的值,粘贴到下面',
|
|
13
|
+
'Cookie 只保存在本机的 Gemini Web 后端里,不会发到别处。'
|
|
14
|
+
].join('\n');
|
|
15
|
+
|
|
16
|
+
// managedBackend is the local backend this plugin set up, when baseURL points at it.
|
|
17
|
+
function managedBackend(baseURL) {
|
|
18
|
+
const local = localBackend();
|
|
19
|
+
return local && local.baseURL === baseURL ? local : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// forwardManaged is forward(), but when the backend this plugin manages can't be reached it
|
|
23
|
+
// starts the backend and tries once more.
|
|
24
|
+
async function forwardManaged(input, init, getAuth, opts) {
|
|
25
|
+
const retryInput = input instanceof Request ? input.clone() : input;
|
|
26
|
+
const res = await forward(input, init, getAuth, opts);
|
|
27
|
+
if (res.headers.get(UNREACHABLE_HEADER) !== 'unreachable') return res;
|
|
28
|
+
let c;
|
|
29
|
+
try {
|
|
30
|
+
c = credentials(await getAuth(), opts);
|
|
31
|
+
} catch {
|
|
32
|
+
return res;
|
|
33
|
+
}
|
|
34
|
+
if (!managedBackend(c.baseURL)) return res;
|
|
35
|
+
try {
|
|
36
|
+
await ensureBackend();
|
|
37
|
+
} catch {
|
|
38
|
+
return res;
|
|
39
|
+
}
|
|
40
|
+
return forward(retryInput, init, getAuth, opts);
|
|
41
|
+
}
|
|
4
42
|
|
|
5
43
|
export const GeminiWebPlugin = async (_input, options = {}) => {
|
|
6
44
|
const opts = settings(options);
|
|
45
|
+
|
|
46
|
+
// Bring a backend set up earlier back after Magpie or the computer restarts. Not awaited:
|
|
47
|
+
// requests that arrive first start it themselves through forwardManaged.
|
|
48
|
+
if (localBackend()) {
|
|
49
|
+
ensureBackend().catch(err => console.error(`[gemini-web] could not start the local backend: ${err.message}`));
|
|
50
|
+
}
|
|
51
|
+
|
|
7
52
|
return {
|
|
8
53
|
config: async cfg => {
|
|
9
54
|
cfg.provider ??= {};
|
|
@@ -13,8 +58,29 @@ export const GeminiWebPlugin = async (_input, options = {}) => {
|
|
|
13
58
|
provider: ID,
|
|
14
59
|
maxConcurrency: 1,
|
|
15
60
|
methods: [{
|
|
61
|
+
type: 'oauth',
|
|
62
|
+
label: '一键配置本地 Gemini Web(推荐)',
|
|
63
|
+
// Installs and starts the pinned backend, then takes the Gemini cookie the user pastes.
|
|
64
|
+
// The result carries the backend's own API key, so Magpie saves an API-key sign-in.
|
|
65
|
+
async authorize() {
|
|
66
|
+
const local = await ensureBackend();
|
|
67
|
+
return {
|
|
68
|
+
url: GEMINI_URL,
|
|
69
|
+
method: 'code',
|
|
70
|
+
instructions: COOKIE_STEPS,
|
|
71
|
+
async callback(code) {
|
|
72
|
+
try {
|
|
73
|
+
await importCookie(local, parseCookieInput(code));
|
|
74
|
+
} catch (err) {
|
|
75
|
+
return { type: 'failed', error: err.message };
|
|
76
|
+
}
|
|
77
|
+
return { type: 'success', key: local.apiKey, metadata: { baseURL: local.baseURL } };
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}, {
|
|
16
82
|
type: 'api',
|
|
17
|
-
label: '
|
|
83
|
+
label: '连接已有后端(自定义地址 + API key)',
|
|
18
84
|
placeholder: 'Backend API key (not a Google cookie)',
|
|
19
85
|
prompts: [{
|
|
20
86
|
type: 'text',
|
|
@@ -43,7 +109,7 @@ export const GeminiWebPlugin = async (_input, options = {}) => {
|
|
|
43
109
|
return {
|
|
44
110
|
apiKey: c.key,
|
|
45
111
|
baseURL: c.baseURL,
|
|
46
|
-
fetch: (input, init) =>
|
|
112
|
+
fetch: (input, init) => forwardManaged(input, init, getAuth, opts)
|
|
47
113
|
};
|
|
48
114
|
}
|
|
49
115
|
},
|
|
@@ -51,7 +117,14 @@ export const GeminiWebPlugin = async (_input, options = {}) => {
|
|
|
51
117
|
id: ID,
|
|
52
118
|
async models(provider, { auth } = {}) {
|
|
53
119
|
if (auth?.type !== 'api') return provider.models;
|
|
54
|
-
|
|
120
|
+
const c = credentials(auth, opts);
|
|
121
|
+
try {
|
|
122
|
+
return await listModels(c, opts);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
if (!err.unreachable || !managedBackend(c.baseURL)) throw err;
|
|
125
|
+
await ensureBackend();
|
|
126
|
+
return listModels(c, opts);
|
|
127
|
+
}
|
|
55
128
|
}
|
|
56
129
|
}
|
|
57
130
|
};
|
package/lib/backend.mjs
CHANGED
|
@@ -12,6 +12,10 @@ const LOW_EFFORTS = new Set(['none', 'minimal', 'low']);
|
|
|
12
12
|
const MODEL_CACHE_MS = 10 * 60 * 1000;
|
|
13
13
|
const knownModels = new Map();
|
|
14
14
|
|
|
15
|
+
// Set on the 502 forward() returns when the backend can't be reached at all, so a caller that
|
|
16
|
+
// manages the backend can start it and retry.
|
|
17
|
+
export const UNREACHABLE_HEADER = 'X-Gemini-Web-Backend';
|
|
18
|
+
|
|
15
19
|
const KNOWN_OPTIONS = new Set([
|
|
16
20
|
'baseURL',
|
|
17
21
|
'allowTools',
|
|
@@ -210,6 +214,7 @@ export async function listModels(c, opts) {
|
|
|
210
214
|
} catch (err) {
|
|
211
215
|
const error = new Error('Failed to reach backend models endpoint: ' + (err?.message || err));
|
|
212
216
|
error.status = 502;
|
|
217
|
+
error.unreachable = true;
|
|
213
218
|
throw error;
|
|
214
219
|
}
|
|
215
220
|
|
|
@@ -485,7 +490,7 @@ export async function forward(input, init, getAuth, opts) {
|
|
|
485
490
|
if (err?.name === 'AbortError') {
|
|
486
491
|
throw err;
|
|
487
492
|
}
|
|
488
|
-
return makeOpenAIError(502, 'Bad gateway: failed to reach backend service');
|
|
493
|
+
return makeOpenAIError(502, 'Bad gateway: failed to reach backend service', { [UNREACHABLE_HEADER]: 'unreachable' });
|
|
489
494
|
}
|
|
490
495
|
|
|
491
496
|
const respHeaders = new Headers(upstreamRes.headers);
|
package/lib/native.mjs
CHANGED
|
@@ -4,13 +4,35 @@ import os from 'node:os';
|
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
6
|
|
|
7
|
-
export const
|
|
8
|
-
|
|
7
|
+
export const BACKEND_VERSION = 'v4.22.0';
|
|
8
|
+
const RELEASE_BASE = `https://github.com/zexadev/gemini-web2api-go/releases/download/${BACKEND_VERSION}/`;
|
|
9
|
+
|
|
10
|
+
// Upstream release assets by `${process.platform}-${process.arch}`, SHA-256 from the release's SHA256SUMS.txt.
|
|
11
|
+
export const PINNED_BINARIES = {
|
|
12
|
+
'darwin-arm64': { asset: 'gemini-web2api-go_v4.22.0_darwin_arm64', sha256: 'c69ca75cf43017cf2a62dbf5c12b07f6bde66f403f714c32209b1ad350ed24c9' },
|
|
13
|
+
'darwin-x64': { asset: 'gemini-web2api-go_v4.22.0_darwin_amd64', sha256: '384e617bcc30aacac6b7c4e4d0490e88d282b13b29f7a0a20d645b69514cd405' },
|
|
14
|
+
'linux-x64': { asset: 'gemini-web2api-go_v4.22.0_linux_amd64', sha256: '21fcc81e0ad8ce0921e6b6c2cafce1089852530142b37e0289355c6e72df5299' },
|
|
15
|
+
'linux-arm64': { asset: 'gemini-web2api-go_v4.22.0_linux_arm64', sha256: 'f2822fa0f1a7d2b37c2bc9cbb30a95c2e8028462453a110389511408bdfd4fd7' },
|
|
16
|
+
'win32-x64': { asset: 'gemini-web2api-go_v4.22.0_windows_amd64.exe', sha256: '17e37f7e5c813b2a8d90c9a0265e6cde3bcfa4e814822af999fe8c4178f02729' },
|
|
17
|
+
'win32-arm64': { asset: 'gemini-web2api-go_v4.22.0_windows_arm64.exe', sha256: 'b42e951bfc08d7fe8bdb436dca87fc6a1b7948bb8d10e110653bcf13a790756c' }
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function pinnedBinary(platform = process.platform, arch = process.arch) {
|
|
21
|
+
const pin = PINNED_BINARIES[`${platform}-${arch}`];
|
|
22
|
+
if (!pin) {
|
|
23
|
+
throw new Error(`No gemini-web2api-go ${BACKEND_VERSION} build for ${platform}/${arch}. Download one manually from https://github.com/zexadev/gemini-web2api-go/releases/tag/${BACKEND_VERSION}`);
|
|
24
|
+
}
|
|
25
|
+
return { url: RELEASE_BASE + pin.asset, sha256: pin.sha256 };
|
|
26
|
+
}
|
|
9
27
|
|
|
10
28
|
export function getDataDir(env = process.env) {
|
|
11
29
|
return env.GEMINI_WEB_DATA_DIR || path.join(os.homedir(), '.local', 'share', 'magpie-gemini-web');
|
|
12
30
|
}
|
|
13
31
|
|
|
32
|
+
export function binaryPath(dataDir, platform = process.platform) {
|
|
33
|
+
return path.join(dataDir, 'bin', platform === 'win32' ? 'gemini-web2api-go.exe' : 'gemini-web2api-go');
|
|
34
|
+
}
|
|
35
|
+
|
|
14
36
|
export function computeFileHash(filePath) {
|
|
15
37
|
const content = fs.readFileSync(filePath);
|
|
16
38
|
return crypto.createHash('sha256').update(content).digest('hex');
|
|
@@ -19,14 +41,11 @@ export function computeFileHash(filePath) {
|
|
|
19
41
|
export async function installBackend(options = {}) {
|
|
20
42
|
const platform = options.platform || process.platform;
|
|
21
43
|
const arch = options.arch || process.arch;
|
|
22
|
-
const
|
|
23
|
-
const
|
|
44
|
+
const pin = options.expectedHash && options.url ? null : pinnedBinary(platform, arch);
|
|
45
|
+
const expectedHash = options.expectedHash || pin.sha256;
|
|
46
|
+
const downloadUrl = options.url || pin.url;
|
|
24
47
|
const fetchFn = options.fetchFn || globalThis.fetch;
|
|
25
48
|
|
|
26
|
-
if (platform !== 'darwin' || arch !== 'arm64') {
|
|
27
|
-
throw new Error('Automatic binary install currently supports macOS arm64 only. For other platforms, download gemini-web2api-go v4.22.0 manually from https://github.com/zexadev/gemini-web2api-go/releases/tag/v4.22.0');
|
|
28
|
-
}
|
|
29
|
-
|
|
30
49
|
const dataDir = options.dataDir || getDataDir();
|
|
31
50
|
fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 });
|
|
32
51
|
fs.chmodSync(dataDir, 0o700);
|
|
@@ -35,7 +54,7 @@ export async function installBackend(options = {}) {
|
|
|
35
54
|
fs.mkdirSync(binDir, { recursive: true, mode: 0o700 });
|
|
36
55
|
fs.chmodSync(binDir, 0o700);
|
|
37
56
|
|
|
38
|
-
const binPath =
|
|
57
|
+
const binPath = binaryPath(dataDir, platform);
|
|
39
58
|
let binaryReady = false;
|
|
40
59
|
|
|
41
60
|
if (fs.existsSync(binPath)) {
|
|
@@ -51,7 +70,7 @@ export async function installBackend(options = {}) {
|
|
|
51
70
|
if (!binaryReady) {
|
|
52
71
|
console.log(`Downloading backend binary from ${downloadUrl}...`);
|
|
53
72
|
const res = await fetchFn(downloadUrl, {
|
|
54
|
-
signal: AbortSignal.timeout(
|
|
73
|
+
signal: AbortSignal.timeout(300000)
|
|
55
74
|
});
|
|
56
75
|
if (!res.ok) {
|
|
57
76
|
throw new Error(`Failed to download binary: HTTP ${res.status}`);
|
|
@@ -78,6 +97,11 @@ export async function installBackend(options = {}) {
|
|
|
78
97
|
}
|
|
79
98
|
}
|
|
80
99
|
|
|
100
|
+
return { binPath, ...ensureDataFiles(dataDir) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ensureDataFiles creates config.json and secrets.json when missing; existing ones are kept.
|
|
104
|
+
function ensureDataFiles(dataDir) {
|
|
81
105
|
const configPath = path.join(dataDir, 'config.json');
|
|
82
106
|
if (!fs.existsSync(configPath)) {
|
|
83
107
|
const defaultConfig = {
|
|
@@ -107,16 +131,58 @@ export async function installBackend(options = {}) {
|
|
|
107
131
|
fs.chmodSync(secretsPath, 0o600);
|
|
108
132
|
}
|
|
109
133
|
|
|
110
|
-
return {
|
|
134
|
+
return { configPath, secretsPath };
|
|
111
135
|
}
|
|
112
136
|
|
|
113
137
|
export function startBackend(options = {}) {
|
|
114
138
|
const dataDir = options.dataDir || getDataDir();
|
|
115
|
-
const
|
|
139
|
+
const expectedHash = options.expectedHash || pinnedBinary().sha256;
|
|
140
|
+
const spawnFn = options.spawnFn || spawn;
|
|
141
|
+
const { binPath, args, env } = prepareLaunch(dataDir, [expectedHash]);
|
|
142
|
+
|
|
143
|
+
const child = spawnFn(binPath, args, {
|
|
144
|
+
env,
|
|
145
|
+
stdio: 'inherit'
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const onSigInt = () => child.kill('SIGINT');
|
|
149
|
+
const onSigTerm = () => child.kill('SIGTERM');
|
|
150
|
+
process.on('SIGINT', onSigInt);
|
|
151
|
+
process.on('SIGTERM', onSigTerm);
|
|
152
|
+
|
|
153
|
+
const cleanupListeners = () => {
|
|
154
|
+
process.removeListener('SIGINT', onSigInt);
|
|
155
|
+
process.removeListener('SIGTERM', onSigTerm);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
child.on('error', () => {
|
|
159
|
+
cleanupListeners();
|
|
160
|
+
console.error('Failed to start backend process');
|
|
161
|
+
process.exitCode = 1;
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
child.on('exit', (code, signal) => {
|
|
165
|
+
cleanupListeners();
|
|
166
|
+
if (typeof options.exitFn === 'function') {
|
|
167
|
+
options.exitFn(code, signal);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (signal) {
|
|
171
|
+
process.kill(process.pid, signal);
|
|
172
|
+
} else {
|
|
173
|
+
process.exit(code ?? 0);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return child;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// prepareLaunch checks the installed backend before it is run: a binary whose hash is one of
|
|
181
|
+
// trustedHashes, a loopback-only config and well-formed secrets. It returns what to spawn.
|
|
182
|
+
function prepareLaunch(dataDir, trustedHashes, platform = process.platform) {
|
|
183
|
+
const binPath = binaryPath(dataDir, platform);
|
|
116
184
|
const configPath = path.join(dataDir, 'config.json');
|
|
117
185
|
const secretsPath = path.join(dataDir, 'secrets.json');
|
|
118
|
-
const expectedHash = options.expectedHash || PINNED_SHA256;
|
|
119
|
-
const spawnFn = options.spawnFn || spawn;
|
|
120
186
|
|
|
121
187
|
if (!fs.existsSync(binPath)) {
|
|
122
188
|
throw new Error(`Binary not found at ${binPath}. Run 'npm run backend:install' first.`);
|
|
@@ -129,8 +195,8 @@ export function startBackend(options = {}) {
|
|
|
129
195
|
}
|
|
130
196
|
|
|
131
197
|
const actualHash = computeFileHash(binPath);
|
|
132
|
-
if (actualHash
|
|
133
|
-
throw new Error(`Binary integrity verification failed: expected ${
|
|
198
|
+
if (!trustedHashes.includes(actualHash)) {
|
|
199
|
+
throw new Error(`Binary integrity verification failed: expected ${trustedHashes.join(' or ')}, got ${actualHash}`);
|
|
134
200
|
}
|
|
135
201
|
|
|
136
202
|
let config;
|
|
@@ -167,41 +233,151 @@ export function startBackend(options = {}) {
|
|
|
167
233
|
ADMIN_TOKEN: secrets.adminToken
|
|
168
234
|
};
|
|
169
235
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
stdio: 'inherit'
|
|
173
|
-
});
|
|
236
|
+
return { binPath, args, env, port: config.port, secrets };
|
|
237
|
+
}
|
|
174
238
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
239
|
+
// trustedHashes is what a backend binary in dataDir may hash to: the pinned upstream build for
|
|
240
|
+
// this platform, plus a local Argon build installed by `backend:install:argon`.
|
|
241
|
+
function trustedHashes(dataDir, platform, arch) {
|
|
242
|
+
const hashes = [];
|
|
243
|
+
try {
|
|
244
|
+
hashes.push(pinnedBinary(platform, arch).sha256);
|
|
245
|
+
} catch {}
|
|
246
|
+
try {
|
|
247
|
+
const argon = JSON.parse(fs.readFileSync(path.join(dataDir, 'argon-build.json'), 'utf8'));
|
|
248
|
+
if (/^[a-f0-9]{64}$/.test(argon.sha256)) hashes.push(argon.sha256);
|
|
249
|
+
} catch {}
|
|
250
|
+
return hashes;
|
|
251
|
+
}
|
|
179
252
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
253
|
+
function readLocalSetup(dataDir) {
|
|
254
|
+
try {
|
|
255
|
+
const config = JSON.parse(fs.readFileSync(path.join(dataDir, 'config.json'), 'utf8'));
|
|
256
|
+
const secrets = JSON.parse(fs.readFileSync(path.join(dataDir, 'secrets.json'), 'utf8'));
|
|
257
|
+
if (config.host !== '127.0.0.1' || !Number.isInteger(config.port) || typeof secrets.apiKey !== 'string' || typeof secrets.adminToken !== 'string') {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
const origin = `http://127.0.0.1:${config.port}`;
|
|
261
|
+
return { origin, baseURL: `${origin}/v1`, apiKey: secrets.apiKey, adminToken: secrets.adminToken };
|
|
262
|
+
} catch {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
184
266
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
267
|
+
// localBackend returns the backend this plugin manages in dataDir ({ origin, baseURL, apiKey,
|
|
268
|
+
// adminToken }), or null when none has been set up there.
|
|
269
|
+
export function localBackend(options = {}) {
|
|
270
|
+
const dataDir = options.dataDir || getDataDir();
|
|
271
|
+
const platform = options.platform || process.platform;
|
|
272
|
+
if (!fs.existsSync(binaryPath(dataDir, platform))) return null;
|
|
273
|
+
return readLocalSetup(dataDir);
|
|
274
|
+
}
|
|
190
275
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
276
|
+
// backendState says whether the backend at setup answers with this setup's API key.
|
|
277
|
+
async function backendState(setup, fetchFn) {
|
|
278
|
+
let res;
|
|
279
|
+
try {
|
|
280
|
+
res = await fetchFn(`${setup.baseURL}/models`, {
|
|
281
|
+
headers: { Authorization: `Bearer ${setup.apiKey}` },
|
|
282
|
+
signal: AbortSignal.timeout(2000)
|
|
283
|
+
});
|
|
284
|
+
} catch {
|
|
285
|
+
return 'down';
|
|
286
|
+
}
|
|
287
|
+
return res.ok ? 'up' : 'foreign';
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
291
|
+
|
|
292
|
+
// withLock serializes setup across plugin hosts (Magpie may run more than one) by a lock file.
|
|
293
|
+
async function withLock(dataDir, fn, timeoutMs) {
|
|
294
|
+
const lockPath = path.join(dataDir, '.setup.lock');
|
|
295
|
+
const deadline = Date.now() + timeoutMs;
|
|
296
|
+
for (;;) {
|
|
297
|
+
try {
|
|
298
|
+
fs.closeSync(fs.openSync(lockPath, 'wx', 0o600));
|
|
299
|
+
break;
|
|
300
|
+
} catch (err) {
|
|
301
|
+
if (err.code !== 'EEXIST') throw err;
|
|
302
|
+
try {
|
|
303
|
+
if (Date.now() - fs.statSync(lockPath).mtimeMs > timeoutMs) {
|
|
304
|
+
fs.rmSync(lockPath, { force: true });
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
} catch {}
|
|
308
|
+
if (Date.now() > deadline) throw new Error(`Timed out waiting for another Gemini Web setup (${lockPath})`);
|
|
309
|
+
await sleep(300);
|
|
196
310
|
}
|
|
197
|
-
|
|
198
|
-
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
return await fn();
|
|
314
|
+
} finally {
|
|
315
|
+
fs.rmSync(lockPath, { force: true });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ensureBackend makes sure the local backend is installed and running, and returns how to reach
|
|
320
|
+
// it. A backend already answering with this setup's key is left alone. Otherwise a missing or
|
|
321
|
+
// untrusted binary is (re)installed from the pinned upstream release, config and secrets are
|
|
322
|
+
// created when missing, and the backend is started in the background (logging to backend.log)
|
|
323
|
+
// so it outlives the caller.
|
|
324
|
+
export async function ensureBackend(options = {}) {
|
|
325
|
+
const dataDir = options.dataDir || getDataDir();
|
|
326
|
+
const platform = options.platform || process.platform;
|
|
327
|
+
const arch = options.arch || process.arch;
|
|
328
|
+
const fetchFn = options.fetchFn || globalThis.fetch;
|
|
329
|
+
const spawnFn = options.spawnFn || spawn;
|
|
330
|
+
const startTimeoutMs = options.startTimeoutMs ?? 20000;
|
|
331
|
+
|
|
332
|
+
let setup = readLocalSetup(dataDir);
|
|
333
|
+
if (setup && fs.existsSync(binaryPath(dataDir, platform)) && await backendState(setup, fetchFn) === 'up') {
|
|
334
|
+
return setup;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 });
|
|
338
|
+
return withLock(dataDir, async () => {
|
|
339
|
+
setup = readLocalSetup(dataDir);
|
|
340
|
+
if (setup && fs.existsSync(binaryPath(dataDir, platform)) && await backendState(setup, fetchFn) === 'up') {
|
|
341
|
+
return setup;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const trusted = trustedHashes(dataDir, platform, arch);
|
|
345
|
+
const binPath = binaryPath(dataDir, platform);
|
|
346
|
+
if (!fs.existsSync(binPath) || !trusted.includes(computeFileHash(binPath))) {
|
|
347
|
+
await installBackend({ dataDir, platform, arch, fetchFn });
|
|
199
348
|
} else {
|
|
200
|
-
|
|
349
|
+
ensureDataFiles(dataDir);
|
|
350
|
+
}
|
|
351
|
+
setup = readLocalSetup(dataDir);
|
|
352
|
+
if (!setup) throw new Error(`Invalid Gemini Web backend config in ${dataDir}`);
|
|
353
|
+
if (await backendState(setup, fetchFn) === 'foreign') {
|
|
354
|
+
throw new Error(`${setup.origin} is already used by another service; change "port" in ${path.join(dataDir, 'config.json')}`);
|
|
201
355
|
}
|
|
202
|
-
});
|
|
203
356
|
|
|
204
|
-
|
|
357
|
+
const launch = prepareLaunch(dataDir, trusted, platform);
|
|
358
|
+
const logPath = path.join(dataDir, 'backend.log');
|
|
359
|
+
const log = fs.openSync(logPath, 'a', 0o600);
|
|
360
|
+
try {
|
|
361
|
+
const child = spawnFn(launch.binPath, launch.args, {
|
|
362
|
+
env: launch.env,
|
|
363
|
+
detached: true,
|
|
364
|
+
stdio: ['ignore', log, log],
|
|
365
|
+
windowsHide: true
|
|
366
|
+
});
|
|
367
|
+
child.on?.('error', () => {});
|
|
368
|
+
if (child.pid) fs.writeFileSync(path.join(dataDir, 'backend.pid'), `${child.pid}\n`, { mode: 0o600 });
|
|
369
|
+
child.unref?.();
|
|
370
|
+
} finally {
|
|
371
|
+
fs.closeSync(log);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const deadline = Date.now() + startTimeoutMs;
|
|
375
|
+
while (Date.now() < deadline) {
|
|
376
|
+
if (await backendState(setup, fetchFn) === 'up') return setup;
|
|
377
|
+
await sleep(300);
|
|
378
|
+
}
|
|
379
|
+
throw new Error(`Gemini Web backend did not start within ${Math.round(startTimeoutMs / 1000)}s; see ${logPath}`);
|
|
380
|
+
}, 600000);
|
|
205
381
|
}
|
|
206
382
|
|
|
207
383
|
export async function statusBackend(options = {}) {
|
package/lib/setup.mjs
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Sign-in helpers for the one-click setup: turn what the user pasted into a Cookie header and
|
|
2
|
+
// add it to the local backend's account pool through its admin API.
|
|
3
|
+
|
|
4
|
+
// The sign-in cookies a gemini.google.com request carries; without these the backend serves
|
|
5
|
+
// the request anonymously (or the account check fails).
|
|
6
|
+
export const REQUIRED_COOKIES = ['SID', 'HSID', 'SSID', 'APISID', 'SAPISID', '__Secure-1PSID'];
|
|
7
|
+
|
|
8
|
+
// Domains whose cookies the browser sends to gemini.google.com.
|
|
9
|
+
const GEMINI_COOKIE_DOMAIN = /^\.?(gemini\.)?google\.com$/i;
|
|
10
|
+
|
|
11
|
+
const COOKIE_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
12
|
+
|
|
13
|
+
function pairsFromHeader(text) {
|
|
14
|
+
const pairs = [];
|
|
15
|
+
for (const part of text.split(/;|\r?\n/)) {
|
|
16
|
+
const p = part.trim();
|
|
17
|
+
const i = p.indexOf('=');
|
|
18
|
+
if (i > 0) pairs.push([p.slice(0, i).trim(), p.slice(i + 1).trim()]);
|
|
19
|
+
}
|
|
20
|
+
return pairs;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// DevTools › Application › Cookies rows copied as text: name, value, domain, path, … separated
|
|
24
|
+
// by tabs. Only cookies for google.com / gemini.google.com are kept.
|
|
25
|
+
function pairsFromTable(text) {
|
|
26
|
+
const pairs = [];
|
|
27
|
+
for (const line of text.split(/\r?\n/)) {
|
|
28
|
+
const cols = line.split('\t').map(c => c.trim());
|
|
29
|
+
if (cols.length < 3 || !COOKIE_NAME.test(cols[0]) || !cols[1]) continue;
|
|
30
|
+
if (GEMINI_COOKIE_DOMAIN.test(cols[2])) pairs.push([cols[0], cols[1]]);
|
|
31
|
+
}
|
|
32
|
+
return pairs;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function pairsFromJSON(text) {
|
|
36
|
+
let v;
|
|
37
|
+
try {
|
|
38
|
+
v = JSON.parse(text);
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
if (Array.isArray(v)) {
|
|
43
|
+
// Cookie-editor style exports: [{ name, value, domain }, …]
|
|
44
|
+
return v.filter(c => c && typeof c.name === 'string' && typeof c.value === 'string' &&
|
|
45
|
+
(typeof c.domain !== 'string' || GEMINI_COOKIE_DOMAIN.test(c.domain)))
|
|
46
|
+
.map(c => [c.name, c.value]);
|
|
47
|
+
}
|
|
48
|
+
if (v && typeof v === 'object') {
|
|
49
|
+
if (typeof v.cookie === 'string') return pairsFromHeader(v.cookie);
|
|
50
|
+
return Object.entries(v).filter(([, val]) => typeof val === 'string').map(([k, val]) => [k, val]);
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// parseCookieInput accepts the Cookie request header (with or without the "cookie:" prefix),
|
|
56
|
+
// rows copied from DevTools' cookie table, or a JSON export, and returns a "k=v; k=v" header.
|
|
57
|
+
// It throws a message meant for the user when the sign-in cookies are missing.
|
|
58
|
+
export function parseCookieInput(input) {
|
|
59
|
+
let text = String(input ?? '').trim().replace(/^['"]|['"]$/g, '').trim();
|
|
60
|
+
text = text.replace(/^cookie\s*:\s*/i, '');
|
|
61
|
+
if (!text) throw new Error('没有收到 Cookie:请粘贴 gemini.google.com 请求头里 cookie 一行的值');
|
|
62
|
+
|
|
63
|
+
let pairs = null;
|
|
64
|
+
if (text.startsWith('{') || text.startsWith('[')) pairs = pairsFromJSON(text);
|
|
65
|
+
if (!pairs && text.includes('\t')) pairs = pairsFromTable(text);
|
|
66
|
+
if (!pairs) pairs = pairsFromHeader(text);
|
|
67
|
+
|
|
68
|
+
const cookies = new Map();
|
|
69
|
+
for (const [name, value] of pairs) {
|
|
70
|
+
if (!COOKIE_NAME.test(name) || /[\x00-\x1f\x7f;]/.test(value) || !value) continue;
|
|
71
|
+
if (!cookies.has(name)) cookies.set(name, value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const missing = REQUIRED_COOKIES.filter(n => !cookies.has(n));
|
|
75
|
+
if (missing.length) {
|
|
76
|
+
throw new Error(`Cookie 不完整,缺少 ${missing.join('、')}。请在开发者工具 Network 面板里,从发往 gemini.google.com 的请求的请求头复制 cookie 一行的完整值(不要从 Application 面板逐条复制)。`);
|
|
77
|
+
}
|
|
78
|
+
return [...cookies].map(([n, v]) => `${n}=${v}`).join('; ');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function adminCall(setup, fetchFn, method, path, body) {
|
|
82
|
+
let res;
|
|
83
|
+
try {
|
|
84
|
+
res = await fetchFn(`${setup.origin}/admin/api/${path}`, {
|
|
85
|
+
method,
|
|
86
|
+
headers: {
|
|
87
|
+
Authorization: `Bearer ${setup.adminToken}`,
|
|
88
|
+
...(body ? { 'Content-Type': 'application/json' } : {})
|
|
89
|
+
},
|
|
90
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
91
|
+
redirect: 'error',
|
|
92
|
+
signal: AbortSignal.timeout(60000)
|
|
93
|
+
});
|
|
94
|
+
} catch (err) {
|
|
95
|
+
throw new Error(`连不上本地 Gemini Web 后端(${setup.origin}):${err?.message || err}`);
|
|
96
|
+
}
|
|
97
|
+
let data = {};
|
|
98
|
+
try {
|
|
99
|
+
data = await res.json();
|
|
100
|
+
} catch {}
|
|
101
|
+
if (!res.ok) throw new Error(`本地后端拒绝了请求(HTTP ${res.status}):${data.error || 'unknown error'}`);
|
|
102
|
+
return data;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// importCookie adds the cookie to the backend's account pool and checks it signs in. An
|
|
106
|
+
// account that fails the check is removed again, and the error says why.
|
|
107
|
+
export async function importCookie(setup, cookie, options = {}) {
|
|
108
|
+
const fetchFn = options.fetchFn || globalThis.fetch;
|
|
109
|
+
const label = options.label || `Magpie ${new Date().toISOString().slice(0, 10)}`;
|
|
110
|
+
const added = await adminCall(setup, fetchFn, 'POST', 'cookies', {
|
|
111
|
+
label,
|
|
112
|
+
cookie,
|
|
113
|
+
note: 'Added by Magpie one-click setup'
|
|
114
|
+
});
|
|
115
|
+
if (!Number.isInteger(added.id)) throw new Error('本地后端没有返回新账号的 id');
|
|
116
|
+
|
|
117
|
+
let check;
|
|
118
|
+
try {
|
|
119
|
+
check = await adminCall(setup, fetchFn, 'POST', `cookies/${added.id}/check`);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
await adminCall(setup, fetchFn, 'DELETE', `cookies/${added.id}`).catch(() => {});
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
if (!check.ok) {
|
|
125
|
+
await adminCall(setup, fetchFn, 'DELETE', `cookies/${added.id}`).catch(() => {});
|
|
126
|
+
throw new Error(`这个 Cookie 无法登录 Gemini:${check.detail || '检测未通过'}`);
|
|
127
|
+
}
|
|
128
|
+
return { id: added.id, detail: check.detail || '' };
|
|
129
|
+
}
|