diff --git a/README.md b/README.md index ee4d590..ee1ddfd 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,7 @@ POST /v1beta/interactions/{id}/cancel ### 管理端与服务状态 -浏览器打开 `http://127.0.0.1:8017/` 进入管理端,页面使用 `/api/*` 接口。本机回环地址访问免登录;设置 `ADMIN_AUTH_ENABLED=true`、`ADMIN_USERNAME` 与 `ADMIN_PASSWORD` 后可从其他设备登录访问。设置页修改的配置写回 `.env`,`LISTEN`、`AUTH_DIR` 与 `RUNTIME_DIR` 重启服务后生效,其余项下一次请求生效。设置页的“关于”显示 GitHub 最新发行版本,确认后下载当前平台的发行包、校验 SHA-256、替换程序并重启到新版本,下载经 `PROXY`;Docker 部署随镜像更新。 +浏览器打开 `http://127.0.0.1:8017/` 进入管理端,页面使用 `/api/*` 接口。本机回环地址访问免登录;设置 `ADMIN_AUTH_ENABLED=true`、`ADMIN_USERNAME` 与 `ADMIN_PASSWORD` 后可从其他设备登录访问。经反向代理访问时原样转发 `Host`(nginx 为 `proxy_set_header Host $http_host;`),管理端按访问的域名识别远程访问并要求登录。设置页修改的配置写回 `.env`,`LISTEN`、`AUTH_DIR` 与 `RUNTIME_DIR` 重启服务后生效,其余项下一次请求生效。设置页的“关于”显示 GitHub 最新发行版本,确认后下载当前平台的发行包、校验 SHA-256、替换程序并重启到新版本,下载经 `PROXY`;Docker 部署随镜像更新。 ## 客户端接入 diff --git a/README_en.md b/README_en.md index a42b7f5..3ac2a48 100644 --- a/README_en.md +++ b/README_en.md @@ -218,7 +218,7 @@ POST /v1beta/interactions/{id}/cancel ### Admin UI and Service Status -Open `http://127.0.0.1:8017/` in a browser for the admin UI, which uses the `/api/*` endpoints. Loopback access needs no sign-in; after setting `ADMIN_AUTH_ENABLED=true`, `ADMIN_USERNAME` and `ADMIN_PASSWORD`, other devices can sign in. Settings saved in the UI are written back to `.env`; `LISTEN`, `AUTH_DIR` and `RUNTIME_DIR` take effect after a restart, the others on the next request. The About section of Settings shows the latest GitHub release; after confirming, the app downloads the package for its platform, verifies the SHA-256, replaces the program and restarts into the new version, downloading through `PROXY`; Docker deployments update with the image. +Open `http://127.0.0.1:8017/` in a browser for the admin UI, which uses the `/api/*` endpoints. Loopback access needs no sign-in; after setting `ADMIN_AUTH_ENABLED=true`, `ADMIN_USERNAME` and `ADMIN_PASSWORD`, other devices can sign in. Behind a reverse proxy, forward `Host` unchanged (`proxy_set_header Host $http_host;` in nginx); the admin UI recognizes remote access by the domain it is reached at and requires sign-in. Settings saved in the UI are written back to `.env`; `LISTEN`, `AUTH_DIR` and `RUNTIME_DIR` take effect after a restart, the others on the next request. The About section of Settings shows the latest GitHub release; after confirming, the app downloads the package for its platform, verifies the SHA-256, replaces the program and restarts into the new version, downloading through `PROXY`; Docker deployments update with the image. ## Client Setup diff --git a/cmd/antigravity2api/main.go b/cmd/antigravity2api/main.go index cdfed07..9d094e4 100644 --- a/cmd/antigravity2api/main.go +++ b/cmd/antigravity2api/main.go @@ -236,7 +236,7 @@ func loginCommand(ctx context.Context, args []string) error { go antigravity.NewPrelogin(device, proxy).Run(prelogin) var authorized *oauth2.Token if *code { - authorized, err = antigravity.LoginTerminal(ctx, proxy, promptCode) + authorized, err = antigravity.LoginTerminal(ctx, proxy, func(link string) (string, error) { return promptCode(ctx, link) }) } else { authorized, err = antigravity.LoginBrowser(ctx, proxy, func(link string) error { fmt.Println("在浏览器中完成 Google 登录:") @@ -249,7 +249,7 @@ func loginCommand(ctx context.Context, args []string) error { return err } if *remote != "" { - return pair(ctx, *remote, *token, authorized) + return pair(ctx, *remote, *token, app.PairingUpload{Token: *authorized, Device: device}) } store, err := auth.Open(cfg.AuthDir, proxy) if err != nil { @@ -263,21 +263,32 @@ func loginCommand(ctx context.Context, args []string) error { return nil } -// promptCode 打印授权链接并读取用户粘贴的授权码 -func promptCode(link string) (string, error) { +// promptCode 打印授权链接并读取用户粘贴的授权码,ctx 结束时返回其错误 +func promptCode(ctx context.Context, link string) (string, error) { fmt.Println("在任意设备的浏览器中打开链接完成 Google 登录,把页面给出的授权码粘贴到这里:") fmt.Println(link) fmt.Print("授权码: ") - line, err := bufio.NewReader(os.Stdin).ReadString('\n') + var line string + var err error + read := make(chan struct{}) + go func() { + defer close(read) + line, err = bufio.NewReader(os.Stdin).ReadString('\n') + }() + select { + case <-read: + case <-ctx.Done(): + return "", ctx.Err() + } if err != nil && !errors.Is(err, io.EOF) { return "", err } return strings.TrimSpace(line), nil } -// pair 把登录得到的 token 经配对令牌提交给远程服务 -func pair(ctx context.Context, remote, token string, authorized *oauth2.Token) error { - body, err := json.Marshal(authorized) +// pair 把登录得到的 token 与设备档案经配对令牌提交给远程服务 +func pair(ctx context.Context, remote, token string, upload app.PairingUpload) error { + body, err := json.Marshal(upload) if err != nil { return err } diff --git a/docs/development.md b/docs/development.md index 1482eee..a12569b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -291,17 +291,17 @@ api 层的公开接口规则: | `region` | `fetchUserInfo` 的 `regionCode` | | `device` | 设备档案 | | `auth_method` | 认证方式,见表下 | -| `wif_provider` | 企业 WIF 账户登录时的 `--provider`,个人账户省略 | +| `wif_provider` | 企业账户 WIF 登录的 provider,个人账户与以 Google 账户登录的企业账户省略 | | `enabled` | 是否参与调度 | | `priority` | 调度优先级,越大越先使用,为 0 时省略 | | `ai_credits` | 额度用尽后继续以 Google One AI 积分生成,关闭时省略 | | `credit_balance`、`credit_minimum` | `loadCodeAssist` 的 `paidTier.availableCredits` 中 `GOOGLE_ONE_AI` 项的余额(上游省略 `creditAmount` 时为 `"0"`)与 `minimumCreditAmountForUsage`,没有该项时省略;登录、冷启动与读取额度时更新 | | `added_at` | 添加时间,RFC 3339 带纳秒 | -- `auth_method` 为空或 `personal_consumer` 时是个人账户,`business_licensed_enterprise` 为企业 WIF 账户 +- `auth_method` 为空或 `personal_consumer` 时是个人账户,`business_licensed_enterprise` 为“Continue with Google Cloud”登录的企业账户 - `added_at` 为 Go `time.Time` 的 JSON;账户按它排序,相同时按 ID -设备档案在账户首次加入时生成,之后保持不变:`login` 与管理端登录的新账户沿用登录前虚拟进程的档案(`installation_id`、系统与时区,用户名与 `app_data_dir` 按邮箱补齐),`import` 与远程配对的新账户随机生成;已有账户再次登录、导入或配对时只更新 `token.json`,档案与其他资料不变。 +设备档案在账户首次加入时生成,之后保持不变:`login` 与管理端登录的新账户沿用登录前虚拟进程的档案(`installation_id`、系统与时区,用户名与 `app_data_dir` 按邮箱补齐),远程配对的新账户沿用 `login --remote` 上传的档案,`import` 的新账户随机生成;已有账户再次登录、导入或配对时更新 `token.json`,refresh token 与原来不同时清空 `project`,档案与其他资料不变。 | 字段 | 取值 | | --- | --- | @@ -430,13 +430,13 @@ api 层的公开接口规则: ### 添加账户 -`login`、`import`、远程配对与管理端登录都把 OAuth token 交给账户库:邮箱取自 `id_token` 载荷,没有 `id_token`(官方客户端保存的 token)时以该 token 请求 userinfo;新账户生成设备档案(来源见“账户目录”)并启用,已有账户更新 token 并清除 `auth_method` 与 `wif_provider`,企业 WIF 登录随后写回这两项。`import` 先读系统凭据库(服务 `gemini`、用户 `antigravity`),读取失败时再读 `~/.gemini/jetski-standalone-oauth-token`,取 JSON 的 `token` 字段,导入这一个账户;凭据的 `auth_method` 不参与,导入的账户为个人账户。`POST /api/accounts/import` 的 `accounts` 因此只有一项。 +`login`、`import`、远程配对与管理端登录都把 OAuth token 交给账户库:邮箱取自 `id_token` 载荷,官方客户端保存的凭据取 `id_token_email`,都没有时以该 token 请求 userinfo;新账户生成设备档案(来源见“账户目录”)并启用,已有账户更新 token,`auth_method` 与 `wif_provider` 改为本次登录的认证方式,refresh token 与原来不同时清空 `project`。`import` 先读系统凭据库(服务 `gemini`、用户 `antigravity`),读取失败时再读 `~/.gemini/jetski-standalone-oauth-token`,取 JSON 的 `token` 字段,导入这一个账户;凭据的 `auth_method` 为 `gcp`(“Continue with Google Cloud”登录)时导入为企业账户,`wif_provider` 写入账户,其余为个人账户。`POST /api/accounts/import` 的 `accounts` 因此只有一项。 账户 JSON 的导出与导入: -- 导出为数组,每项为 `email`、`refresh_token`、`label`、`proxy`、`priority`(为 0 时省略)、`enabled`、`ai_credits`(关闭时省略)与 `device`(设备档案),没有 token 的账户不导出;Antigravity Manager 按 `email` 与 `refresh_token` 导入同一文件 +- 导出为数组,每项为 `email`、`refresh_token`、`label`、`proxy`、`priority`(为 0 时省略)、`enabled`、`ai_credits`(关闭时省略)、`auth_method` 与 `wif_provider`(个人账户省略)与 `device`(设备档案),没有 token 的账户不导出;Antigravity Manager 按 `email` 与 `refresh_token` 导入同一文件 - 导入在任意层级中把带 `refresh_token`、`refreshToken`(`|` 之后的项目 ID 去掉)或 `token.refresh_token` 且以 `1//` 开头的对象各作为一个账户,`platform` 不是 `antigravity` 的对象跳过,同一 refresh token 只导入一次;整段内容不是合法 JSON 时取文本中全部 `1//` 开头的 token。覆盖的格式为 Antigravity Manager 的导出数组与 `accounts` 对象、CLIProxyAPI 与 gcli2api 的账户文件、opencode-antigravity-auth 与 antigravity-claude-proxy 的 `accounts.json`、sub2api 的数据导出 -- 每个账户以 refresh token 换取 access token 并读取 userinfo 的邮箱后按上文写入账户库,同时最多 4 个;项中的 `device` 带 `installation_id`(本服务导出的文件)时新账户沿用该设备档案,并写回备注名、代理、优先级、启用状态与 AI 积分设置(省略的优先级与 AI 积分按 0 与关闭写回),其他来源的设置不写回 +- 每个账户以 refresh token 换取 access token 并读取 userinfo 的邮箱后按上文写入账户库,同时最多 4 个;这两个请求经该账户的代理发出:项中带 `proxy` 时用它(不是 `direct` 与 http、https、socks5 URL 时该项失败),否则用代理池按项中 `email` 绑定的代理,代理池为空时用 `PROXY`,项中 `email` 没有成为账户时解除绑定;项中的 `device` 带 `installation_id`(本服务导出的文件)时新账户沿用该设备档案,沿用项中的认证方式与 WIF provider,并写回备注名、代理、优先级、启用状态与 AI 积分设置(省略的优先级与 AI 积分按 0 与关闭写回),其他来源的设置不写回 系统凭据库经 `zalando/go-keyring` v0.2.8 读写,与官方客户端使用同一位置: @@ -454,7 +454,7 @@ api 层的公开接口规则: 1. 找到官方客户端,结束它的全部进程并等待退出(下表) 2. 把 token 写入官方客户端的凭据存储(下表) -3. 重新启动官方客户端,language_server 启动时读取新 token +3. 重新启动官方客户端,language_server 启动时读取新 token;写入失败时同样重新启动,并返回写入的错误 | 步骤 | Windows | macOS | Linux | | --- | --- | --- | --- | @@ -470,7 +470,7 @@ api 层的公开接口规则: - Linux 的可执行文件为 PATH 中 `antigravity` 解析符号链接后的文件,安装目录为它所在的目录,启动时工作目录为安装目录 - macOS 与 Linux 在 `pgrep -f` 查不到进程时视为已退出 -写入的凭据为官方客户端登录后保存的 JSON `{"token":{"access_token","token_type","refresh_token","expiry"},"auth_method":"consumer"}`,位置取决于标记文件 `~/.gemini/cache/antigravity-keyring-unavailable`: +写入的凭据为官方客户端登录后保存的 JSON `{"token":{"access_token","token_type","refresh_token","expiry"},"auth_method":"consumer"}`,企业账户的 `auth_method` 为 `gcp`,另带 `id_token_email`(邮箱)与 `wif_provider`、`saved_wif_provider`(账户的 `wif_provider`,为空时省略),与官方客户端登录后、选择许可证前保存的凭据相同,官方客户端启动后选择许可证;位置取决于标记文件 `~/.gemini/cache/antigravity-keyring-unavailable`: | 情形 | 写入 | | --- | --- | @@ -490,7 +490,7 @@ api 层的公开接口规则: 1. 按添加时间处理启用账户,跳过已初始化且代理与 refresh token 都未变的账户;相邻两个账户的初始化相隔随机 1–3 秒开始、彼此并发,全部结束后刷新结束 2. 每个账户以设备档案与代理创建上游客户端,读取 `RUNTIME_DIR/hub/<账户>.json`,立即开始 unleash 与 clearcut 上报两项伴随请求,0.6–0.8 秒后初始化 -3. 企业账户(`auth_method` 为 `business_licensed_enterprise`)走 `BusinessInitialize`;个人账户在 `project` 为空或 refresh token 已变时走登录序列,其余走冷启动序列(序列见协议参考)。`fetchUserInfo` 显示遥测开启时发 `setUserSettings` 关闭 +3. 企业账户(`auth_method` 为 `business_licensed_enterprise`)走 `BusinessInitialize`;个人账户在 `project` 为空或 refresh token 与调度中的客户端不同时走登录序列(新账户与换了 refresh token 的再次登录),其余走冷启动序列(序列见协议参考);账户在等待初始化期间被删除时不初始化。`fetchUserInfo` 显示遥测开启时发 `setUserSettings` 关闭 4. 成功时以目录的公开条目加入账户池,记录额度快照并开始其余伴随请求;失败时停止该账户的全部伴随请求,记下原因并移出调度 5. 停用与已删除的账户移出调度,停止全部伴随请求 @@ -682,7 +682,7 @@ api 层的公开接口规则: 管理 API 挂在 `/api/`,与公开 API 共用监听: -- 只接受同源请求:`Sec-Fetch-Site: cross-site` 或 `Origin` 与 `Host` 不一致时返回 403 `admin_origin_forbidden` +- 只接受同源请求:`Sec-Fetch-Site: cross-site` 或 `Origin` 与 `Host` 不一致时返回 403 `admin_origin_forbidden`,带 `Origin` 时消息写出 `Origin` 与 `Host` 的值;反向代理原样转发 `Host`(nginx 为 `proxy_set_header Host $http_host`) - 本机回环访问免登录:对端地址是回环 IP,`Host` 的主机部分为 `localhost`(不区分大小写)或回环 IP(如 `localhost:8017`、`127.0.0.1:8017`、`[::1]:8017`),且没有 `X-Forwarded-For`、`Forwarded`、`X-Real-IP` - 远程访问需要 `ADMIN_AUTH_ENABLED=true`,否则返回 401 `admin_remote_disabled`;未登录返回 401 `admin_login_required` - 会话 Cookie `antigravity_admin`:12 小时,`Path=/api`、HttpOnly、SameSite=Strict,经 TLS 或 `X-Forwarded-Proto: https` 时带 Secure;会话结束时取消该会话的进行中管理请求 @@ -757,7 +757,7 @@ api 层的公开接口规则: | `POST /api/logins/{id}/code` | `{"code":"<授权码或回调地址>"}` | 登录结束后的登录状态 | 400 `invalid_request`、`login_callback_rejected`,404 `login_not_found`,409 `login_code_submitted` | | `DELETE /api/logins/{id}` | — | 204 | 404 `login_not_found` | | `POST /api/pairing` | — | 201 `{"token","expires_at"}` | — | -| `POST /api/pairing/accounts` | `oauth2.Token` JSON,`Authorization: Bearer <令牌>` | 201 `{"account":"<邮箱>"}` | 400 `invalid_request`,401 `pairing_token_invalid`,502 `pairing_import_failed` | +| `POST /api/pairing/accounts` | `oauth2.Token` 的各字段与 `device`(设备档案)组成的 JSON,`Authorization: Bearer <令牌>` | 201 `{"account":"<邮箱>"}` | 400 `invalid_request`,401 `pairing_token_invalid`,502 `pairing_import_failed` | | `GET /api/models` | — | `{"models":[公开模型对象加 "accounts"]}` | 502 `models_failed` | | `GET /api/requests` | — | `{"requests":[请求记录]}`,按开始时间升序 | — | | `POST /api/requests/{id}/cancel` | — | 204 | 404 `request_not_found` | @@ -831,8 +831,8 @@ api 层的公开接口规则: 远程配对:`POST /api/pairing` 生成 10 分钟有效的令牌,同时只有一个有效令牌,服务只保存其 SHA-256。`login --remote <地址> --token <令牌>`: - 本机:与 `login` 相同地运行登录前虚拟进程并完成浏览器或授权码登录,拿到 token 后不写本机 `auth/` -- 上传:把 `oauth2.Token` 的 JSON(`access_token`、`token_type`、`refresh_token`、`expiry`,不含 `id_token`)以 `Authorization: Bearer <令牌>` POST 到 `<地址>/api/pairing/accounts`,经进程环境变量的代理发出;返回 201 以外时打印 `远程服务返回 <状态>: <正文>` 并失败 -- 远程:以 userinfo 取得邮箱,新账户随机生成设备档案,已有账户只更新 token;成功返回 201 `{"account": "<邮箱>"}` 并使令牌失效,本机打印 `已添加账户 <邮箱> 到 <地址>` +- 上传:把 `oauth2.Token` 的字段(`access_token`、`token_type`、`refresh_token`、`expiry`,不含 `id_token`)与 `device`(登录前虚拟进程的设备档案)组成的 JSON 以 `Authorization: Bearer <令牌>` POST 到 `<地址>/api/pairing/accounts`,经进程环境变量的代理发出;返回 201 以外时打印 `远程服务返回 <状态>: <正文>` 并失败 +- 远程:以 userinfo 取得邮箱,新账户沿用上传的设备档案(没有 `device.installation_id` 时随机生成),已有账户只更新 token;成功返回 201 `{"account": "<邮箱>"}` 并使令牌失效,本机打印 `已添加账户 <邮箱> 到 <地址>` ### 设置与系统提示预览 @@ -1168,7 +1168,7 @@ curl "/v1beta/models/:generateContent" \ #### 账户批量操作与排序 - 勾选账户后显示批量操作栏:启用、停用、刷新额度、重新初始化(只作用于所选账户中 `state` 为 `error` 的账户)、删除;对所选账户逐个调用单账户接口,结束后提示完成数量与失败的账户;批量删除 5 秒内可撤销 -- 删除账户(单个与批量):账户行立即隐藏,5 秒后发出 `DELETE /api/accounts/{id}`;5 秒内撤销,或经导入与登录再次加入同一账户时不发出删除并恢复显示;删除完成且账户列表刷新后不再隐藏该账户,之后再次加入时照常显示 +- 删除账户(单个与批量):账户行立即隐藏,5 秒后发出 `DELETE /api/accounts/{id}`;5 秒内撤销,或经导入与登录再次加入同一账户时不发出删除并恢复显示;5 秒内关闭或刷新页面时立即发出删除;删除完成且账户列表刷新后不再隐藏该账户,之后再次加入时照常显示 - 排序:按添加时间(默认);按重置时间,取各额度桶中剩余比例低于 100% 的最早 `reset_time`,从早到晚;按最近使用,取近 30 天用量中各账户的 `last_at`,从近到远。没有对应时间的账户排在后面,保持添加顺序 #### 额度与额度最多的账户 diff --git a/docs/protocol.md b/docs/protocol.md index 50ceb98..eaa9c48 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -263,13 +263,16 @@ client_id=1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent. ### token 刷新 -`NewTokenSource(token, method, proxy, save)` 以 `oauth2.ReuseTokenSourceWithExpiry` 包装 OAuth 客户端的 `TokenSource`,提前量为 300 秒;`method` 为账户的 `auth_method`: +`NewTokenSource(token, method, provider, proxy, save)` 以 `oauth2.ReuseTokenSourceWithExpiry` 包装 OAuth 客户端的 `TokenSource`,提前量为 300 秒;`method` 与 `provider` 为账户的 `auth_method` 与 `wif_provider`,客户端的选择与官方客户端的 `getOAuthConfig` 相同: -| `method` | 客户端 | token 端点 | 刷新表单 | +| 账户 | 客户端 | token 端点 | 刷新表单 | | --- | --- | --- | --- | -| `business_licensed_enterprise` | `OAuthConfigWIF` | `https://sts.googleapis.com/v1/oauthtoken` | `client_id`、`grant_type=refresh_token`、`refresh_token` | +| `wif_provider` 非空 | `OAuthConfigWIF` | `https://sts.googleapis.com/v1/oauthtoken` | `client_id`、`grant_type=refresh_token`、`refresh_token` | +| `business_licensed_enterprise`,`wif_provider` 为空 | `OAuthConfigCloud` | `https://oauth2.googleapis.com/token` | `client_id`、`client_secret`、`grant_type=refresh_token`、`refresh_token` | | 其他 | `OAuthConfig` | `https://oauth2.googleapis.com/token` | `client_id`、`client_secret`、`grant_type=refresh_token`、`refresh_token` | +`OAuthConfigCloud` 为“Continue with Google Cloud”以 Google 账户登录的客户端 `884354919052-36trc1jjb3tguiac32ov6cod268c5blh.apps.googleusercontent.com`,scope 为个人账户的前五项,没有 `aicode`。 + - 时机:access token 剩余有效期不足 300 秒后的下一次上游请求前刷新,与官方客户端相同 - 写回:得到的 access token 与上一次不同时调用 `save` 写回 `token.json`,`save` 出错时本次请求失败 - 传输:刷新请求经账户代理与克隆的 `http.DefaultTransport` 发出 diff --git a/internal/antigravity/oauth.go b/internal/antigravity/oauth.go index 13f005f..5c4dff6 100644 --- a/internal/antigravity/oauth.go +++ b/internal/antigravity/oauth.go @@ -40,6 +40,20 @@ var OAuthConfig = oauth2.Config{ }, } +// OAuthConfigCloud 为官方客户端“Continue with Google Cloud”以 Google 账户登录使用的 OAuth 客户端 +var OAuthConfigCloud = oauth2.Config{ + ClientID: "884354919052-36trc1jjb3tguiac32ov6cod268c5blh.apps.googleusercontent.com", + ClientSecret: "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", + Endpoint: OAuthConfig.Endpoint, + Scopes: []string{ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/cclog", + "https://www.googleapis.com/auth/experimentsandconfigs", + }, +} + // exchangeContext 返回换取 token 的上下文,请求经克隆的 http.DefaultTransport 与代理发出 func exchangeContext(ctx context.Context, proxy Proxy) context.Context { return context.WithValue(ctx, oauth2.HTTPClient, &http.Client{Transport: webTransport(proxy)}) @@ -129,11 +143,14 @@ type TokenSource struct { token *oauth2.Token } -// NewTokenSource 以已保存的 token 创建 token 源,认证方式为 AuthMethodEnterprise 时经 OAuthConfigWIF 的 STS 刷新,其余经 OAuthConfig 刷新;刷新请求经克隆的 http.DefaultTransport 与账户代理发出 -func NewTokenSource(token *oauth2.Token, method string, proxy Proxy, save func(*oauth2.Token) error) *TokenSource { +// NewTokenSource 以已保存的 token 创建 token 源,OAuth 客户端的选择与官方客户端相同:provider 非空时经 OAuthConfigWIF 的 STS 刷新,认证方式为 AuthMethodEnterprise 时经 OAuthConfigCloud 刷新,其余经 OAuthConfig 刷新;刷新请求经克隆的 http.DefaultTransport 与账户代理发出 +func NewTokenSource(token *oauth2.Token, method, provider string, proxy Proxy, save func(*oauth2.Token) error) *TokenSource { config := OAuthConfig - if method == AuthMethodEnterprise { + switch { + case provider != "": config = OAuthConfigWIF + case method == AuthMethodEnterprise: + config = OAuthConfigCloud } ctx := exchangeContext(context.Background(), proxy) return &TokenSource{ctx: ctx, config: config, save: save, source: oauth2.ReuseTokenSourceWithExpiry(token, config.TokenSource(ctx, token), refreshAhead), token: token} diff --git a/internal/app/admin.go b/internal/app/admin.go index 6042b46..deb8358 100644 --- a/internal/app/admin.go +++ b/internal/app/admin.go @@ -101,16 +101,6 @@ func (s *server) findAccount(id string) (account, bool) { return account{}, false } -// stored 返回账户目录中的账户,不存在时返回 nil -func (s *server) stored(id string) *auth.Account { - for _, account := range s.deps.Store.Accounts() { - if account.ID == id { - return account - } - } - return nil -} - // updateAccount 启用或停用账户、设置账户代理、调度优先级、备注名与 AI 积分超额使用并在后台刷新账户池,代理变化的账户在刷新时重新初始化 func (s *server) updateAccount(w http.ResponseWriter, r *http.Request) { var input struct { @@ -128,32 +118,35 @@ func (s *server) updateAccount(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return } - stored := s.stored(r.PathValue("id")) - if stored == nil { - writeError(w, http.StatusNotFound, "account_not_found", "账户不存在: "+r.PathValue("id")) - return - } if input.Proxy != nil { - proxy := strings.TrimSpace(*input.Proxy) - if err := config.ValidateProxy(proxy); err != nil && proxy != proxypool.Direct { + *input.Proxy = strings.TrimSpace(*input.Proxy) + if err := config.ValidateProxy(*input.Proxy); err != nil && *input.Proxy != proxypool.Direct { writeError(w, http.StatusBadRequest, "invalid_proxy", err.Error()) return } - stored.Proxy = proxy - } - if input.Enabled != nil { - stored.Enabled = *input.Enabled } - if input.Priority != nil { - stored.Priority = *input.Priority - } - if input.Label != nil { - stored.Label = strings.TrimSpace(*input.Label) - } - if input.AICredits != nil { - stored.AICredits = *input.AICredits + stored, ok, err := s.deps.Store.Update(r.PathValue("id"), func(account *auth.Account) { + if input.Proxy != nil { + account.Proxy = *input.Proxy + } + if input.Enabled != nil { + account.Enabled = *input.Enabled + } + if input.Priority != nil { + account.Priority = *input.Priority + } + if input.Label != nil { + account.Label = strings.TrimSpace(*input.Label) + } + if input.AICredits != nil { + account.AICredits = *input.AICredits + } + }) + if !ok { + writeError(w, http.StatusNotFound, "account_not_found", "账户不存在: "+r.PathValue("id")) + return } - if err := s.deps.Store.Save(stored); err != nil { + if err != nil { writeError(w, http.StatusInternalServerError, "account_update_failed", err.Error()) return } @@ -184,7 +177,7 @@ func (s *server) updateAccount(w http.ResponseWriter, r *http.Request) { // deleteAccount 删除账户并在后台刷新账户池 func (s *server) deleteAccount(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - if s.stored(id) == nil { + if _, ok := s.deps.Store.Account(id); !ok { writeError(w, http.StatusNotFound, "account_not_found", "账户不存在: "+id) return } @@ -250,7 +243,7 @@ func (s *server) clearCooldown(w http.ResponseWriter, r *http.Request) { // switchAccount 把账户写入本机官方客户端并重新启动官方客户端 func (s *server) switchAccount(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - if s.stored(id) == nil { + if _, ok := s.deps.Store.Account(id); !ok { writeError(w, http.StatusNotFound, "account_not_found", "账户不存在: "+id) return } diff --git a/internal/app/auth.go b/internal/app/auth.go index 074ed4d..2c8eb91 100644 --- a/internal/app/auth.go +++ b/internal/app/auth.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "crypto/subtle" + "fmt" "net" "net/http" "net/url" @@ -82,7 +83,11 @@ func (s *server) requireAdmin(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") if !sameOrigin(r) { - writeError(w, http.StatusForbidden, "admin_origin_forbidden", "管理 API 只接受同源请求") + message := "管理 API 只接受同源请求" + if origin := r.Header.Get("Origin"); origin != "" { + message += fmt.Sprintf(":Origin 为 %s,Host 为 %s,经反向代理访问时原样转发 Host", origin, r.Host) + } + writeError(w, http.StatusForbidden, "admin_origin_forbidden", message) return } if r.URL.Path == sessionPath || loopback(r) { diff --git a/internal/app/pairing.go b/internal/app/pairing.go index f91a586..fe36faa 100644 --- a/internal/app/pairing.go +++ b/internal/app/pairing.go @@ -11,6 +11,8 @@ import ( "time" "golang.org/x/oauth2" + + "github.com/Mag1cFall/Antigravity2API/internal/antigravity" ) // pairingLifetime 是远程配对令牌的有效期 @@ -22,6 +24,12 @@ type pairingToken struct { ExpiresAt time.Time `json:"expires_at"` } +// PairingUpload 是 login --remote 上传的正文:本机登录得到的 OAuth token 与登录开始时生成的设备档案 +type PairingUpload struct { + oauth2.Token + Device antigravity.Device `json:"device"` +} + // pairing 保存当前唯一有效配对令牌的摘要,令牌成功导入一个账户后失效 type pairing struct { mu sync.Mutex @@ -60,7 +68,7 @@ func (s *server) createPairing(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, token) } -// pairAccount 凭配对令牌导入本机登录得到的 OAuth token,不经过管理会话 +// pairAccount 凭配对令牌导入本机登录得到的 OAuth token 与设备档案,不经过管理会话 func (s *server) pairAccount(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") scheme, value, _ := strings.Cut(r.Header.Get("Authorization"), " ") @@ -68,12 +76,12 @@ func (s *server) pairAccount(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusUnauthorized, "pairing_token_invalid", "配对令牌无效或已过期") return } - var token oauth2.Token - if err := decodeJSON(r, &token); err != nil { + var upload PairingUpload + if err := decodeJSON(r, &upload); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return } - added, err := s.deps.Store.Add(r.Context(), &token) + added, err := s.deps.Store.AddWithDevice(r.Context(), &upload.Token, upload.Device) if err != nil { slog.ErrorContext(r.Context(), "远程配对导入失败 | 错误="+err.Error(), "source", "auth") writeError(w, http.StatusBadGateway, "pairing_import_failed", err.Error()) diff --git a/internal/app/transfer.go b/internal/app/transfer.go index ec439b7..f5f56cc 100644 --- a/internal/app/transfer.go +++ b/internal/app/transfer.go @@ -10,8 +10,6 @@ import ( "sync" "time" - "golang.org/x/oauth2" - "github.com/Mag1cFall/Antigravity2API/internal/auth" ) @@ -39,7 +37,7 @@ func (s *server) exportAccounts(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, items) } -// importAccountFile 导入账户 JSON 或含 refresh token 的文本:同时最多 transferWorkers 个账户以 refresh token 换取 access token、读取邮箱后写入,本服务导出的资料一并写回,返回导入的账户与失败项并在后台刷新账户池 +// importAccountFile 导入账户 JSON 或含 refresh token 的文本:同时最多 transferWorkers 个账户以 refresh token 换取 access token、读取邮箱后写入,返回导入的账户与失败项并在后台刷新账户池 func (s *server) importAccountFile(w http.ResponseWriter, r *http.Request) { data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxTransferSize)) if err != nil { @@ -62,14 +60,14 @@ func (s *server) importAccountFile(w http.ResponseWriter, r *http.Request) { slots <- struct{}{} workers.Go(func() { defer func() { <-slots }() - id, err := s.importTransfer(r, item) + account, err := s.deps.Service.Import(r.Context(), item) mu.Lock() defer mu.Unlock() if err != nil { failed = append(failed, transferFailure{Account: cmp.Or(item.Email, item.RefreshToken[:min(len(item.RefreshToken), 10)]+"…"), Error: err.Error()}) return } - imported = append(imported, id) + imported = append(imported, account.ID) }) } workers.Wait() @@ -79,19 +77,3 @@ func (s *server) importAccountFile(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, map[string]any{"accounts": imported, "failed": failed}) } - -// importTransfer 以导入项的 refresh token 写入账户,再写回本服务导出的备注名、代理、优先级、启用状态与 AI 积分设置 -func (s *server) importTransfer(r *http.Request, item auth.Transfer) (string, error) { - account, err := s.deps.Store.AddWithDevice(r.Context(), &oauth2.Token{RefreshToken: item.RefreshToken}, item.Device) - if err != nil { - return "", err - } - if item.Device.InstallationID == "" { - return account.ID, nil - } - account.Label, account.Proxy, account.Priority, account.AICredits = item.Label, item.Proxy, item.Priority, item.AICredits - if item.Enabled != nil { - account.Enabled = *item.Enabled - } - return account.ID, s.deps.Store.Save(account) -} diff --git a/internal/auth/official.go b/internal/auth/official.go index c5ce7ae..f9fb2de 100644 --- a/internal/auth/official.go +++ b/internal/auth/official.go @@ -3,6 +3,7 @@ package auth import ( "context" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -10,6 +11,8 @@ import ( "github.com/zalando/go-keyring" "golang.org/x/oauth2" + + "github.com/Mag1cFall/Antigravity2API/internal/antigravity" ) const ( @@ -23,15 +26,22 @@ const ( officialMarker = "antigravity-keyring-unavailable" // cliFile 为官方 agy CLI 在系统凭据库不可用时保存凭据的会话文件,相对 ~/.gemini cliFile = "antigravity-cli/antigravity-oauth-token" + // officialConsumer 为官方客户端凭据中个人账户的 auth_method + officialConsumer = "consumer" + // officialCloud 为官方客户端凭据中“Continue with Google Cloud”登录的 auth_method + officialCloud = "gcp" ) -// officialCredential 表示官方客户端保存的凭据 JSON +// officialCredential 表示官方客户端保存的凭据 JSON,字段与其 StoredToken 相同;企业账户的项目、地区与许可证由官方客户端选择许可证后写入 type officialCredential struct { - Token oauth2.Token `json:"token"` - AuthMethod string `json:"auth_method,omitempty"` + Token oauth2.Token `json:"token"` + AuthMethod string `json:"auth_method,omitempty"` + IDTokenEmail string `json:"id_token_email,omitempty"` + WIFProvider string `json:"wif_provider,omitempty"` + SavedWIFProvider string `json:"saved_wif_provider,omitempty"` } -// ImportOfficial 读取本机官方客户端保存的 OAuth token 并作为账户加入 store +// ImportOfficial 读取本机官方客户端保存的 OAuth token 并作为账户加入 store,auth_method 为 gcp 的凭据成为企业账户并沿用其 WIF provider func ImportOfficial(ctx context.Context, store *Store) ([]*Account, error) { data, err := readOfficial() if err != nil { @@ -41,15 +51,20 @@ func ImportOfficial(ctx context.Context, store *Store) ([]*Account, error) { if err := json.Unmarshal(data, &credential); err != nil { return nil, fmt.Errorf("解析官方客户端凭据: %w", err) } - account, err := store.Add(ctx, &credential.Token) + from := identity{email: credential.IDTokenEmail} + if credential.AuthMethod == officialCloud { + from.method, from.provider = antigravity.AuthMethodEnterprise, credential.WIFProvider + } + account, err := store.add(ctx, &credential.Token, from, store.proxy, func(*Account) {}) if err != nil { return nil, err } return []*Account{account}, nil } -// SwitchOfficial 结束运行中的官方客户端,把账户 token 写入官方客户端的凭据存储后以原启动参数重新启动它 +// SwitchOfficial 结束运行中的官方客户端,把账户的 token 写入官方客户端的凭据存储后以原启动参数重新启动它,写入失败时同样重新启动 func SwitchOfficial(store *Store, id string) error { + account, _ := store.Account(id) token := store.Token(id) if token == nil { return fmt.Errorf("账户不存在: %s", id) @@ -62,18 +77,20 @@ func SwitchOfficial(store *Store, id string) error { if err != nil { return err } - if err := writeOfficial(token); err != nil { - return err - } - return startOfficial(app, args) + return errors.Join(writeOfficial(account, token), startOfficial(app, args)) } -// writeOfficial 按官方客户端登录后保存的格式写入凭据:keyring 不可用标记 1 小时内有效时写 ~/.gemini/jetski-standalone-oauth-token,系统凭据库已有可读凭据时一并更新(官方客户端优先读取它),否则写系统凭据库;系统凭据库不可写时与官方客户端一样写入标记并改写该文件;agy CLI 的会话文件存在时同样改写 -func writeOfficial(token *oauth2.Token) error { - data, err := json.Marshal(officialCredential{ +// writeOfficial 按官方客户端登录后保存的格式写入凭据,企业账户写 auth_method gcp、邮箱与 WIF provider:keyring 不可用标记 1 小时内有效时写 ~/.gemini/jetski-standalone-oauth-token,系统凭据库已有可读凭据时一并更新(官方客户端优先读取它),否则写系统凭据库;系统凭据库不可写时与官方客户端一样写入标记并改写该文件;agy CLI 的会话文件存在时同样改写 +func writeOfficial(account Account, token *oauth2.Token) error { + credential := officialCredential{ Token: oauth2.Token{AccessToken: token.AccessToken, TokenType: token.Type(), RefreshToken: token.RefreshToken, Expiry: token.Expiry}, - AuthMethod: "consumer", - }) + AuthMethod: officialConsumer, + } + if account.AuthMethod == antigravity.AuthMethodEnterprise { + credential.AuthMethod, credential.IDTokenEmail = officialCloud, account.Email + credential.WIFProvider, credential.SavedWIFProvider = account.WIFProvider, account.WIFProvider + } + data, err := json.Marshal(credential) if err != nil { return err } diff --git a/internal/auth/store.go b/internal/auth/store.go index 90062d9..91a2dea 100644 --- a/internal/auth/store.go +++ b/internal/auth/store.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "encoding/json" "errors" + "fmt" "io/fs" "os" "path/filepath" @@ -37,9 +38,9 @@ type Account struct { PaidTier string `json:"paid_tier,omitempty"` Region string `json:"region,omitempty"` Device antigravity.Device `json:"device"` - // AuthMethod 为账户认证方式,空与 personal_consumer 为个人账户,business_licensed_enterprise 为企业 WIF 账户 + // AuthMethod 为账户认证方式,空与 personal_consumer 为个人账户,business_licensed_enterprise 为“Continue with Google Cloud”登录的企业账户 AuthMethod string `json:"auth_method,omitempty"` - // WIFProvider 为企业 WIF 账户的 provider 名 + // WIFProvider 为企业账户 WIF 登录的 provider 名,以 Google 账户登录的企业账户为空 WIFProvider string `json:"wif_provider,omitempty"` Enabled bool `json:"enabled"` // Priority 为调度优先级,越大越先使用,默认 0 @@ -155,10 +156,23 @@ func (s *Store) Accounts() []*Account { return accounts } +// Account 返回账户库中的账户副本,不存在时返回 false +func (s *Store) Account(id string) (Account, bool) { + s.mu.Lock() + defer s.mu.Unlock() + account, ok := s.accounts[id] + return account, ok +} + // Save 原子写回 account.json func (s *Store) Save(account *Account) error { s.mu.Lock() defer s.mu.Unlock() + return s.save(account) +} + +// save 在持有锁时原子写回 account.json +func (s *Store) save(account *Account) error { if err := writeJSON(filepath.Join(s.dir, account.ID, accountFile), account); err != nil { return err } @@ -186,13 +200,6 @@ func (s *Store) Update(id string, change func(*Account)) (Account, bool, error) return updated, true, nil } -// SaveToken 原子写回 token.json -func (s *Store) SaveToken(id string, token *oauth2.Token) error { - s.mu.Lock() - defer s.mu.Unlock() - return s.saveToken(id, token) -} - // UpdateToken 原子写回仍在账户库中的账户的 token.json,账户已删除时不写入 func (s *Store) UpdateToken(id string, token *oauth2.Token) error { s.mu.Lock() @@ -226,57 +233,60 @@ func (s *Store) Token(id string) *oauth2.Token { return s.tokens[id] } -// Add 以邮箱为 ID 写入账户,新账户生成设备档案 -func (s *Store) Add(ctx context.Context, token *oauth2.Token) (*Account, error) { - return s.AddWithDevice(ctx, token, antigravity.Device{}) +// AddWithDevice 以个人账户登录得到的 token 写入账户,device 为登录开始时生成的设备档案 +func (s *Store) AddWithDevice(ctx context.Context, token *oauth2.Token, device antigravity.Device) (*Account, error) { + return s.add(ctx, token, identity{device: device}, s.proxy, func(*Account) {}) } -// AddWithDevice 取 id_token 中的邮箱,没有 id_token 时请求 userinfo,以邮箱为 ID 写入个人账户的 token 与资料;device 带 installation_id 时用它补齐用户名与目录,否则新账户生成设备档案,已有账户清除企业认证方式与 provider、保留其余字段;项目、套餐与地区由服务初始化账户时写回 -func (s *Store) AddWithDevice(ctx context.Context, token *oauth2.Token, device antigravity.Device) (*Account, error) { - latest := token - email := tokenEmail(token) +// AddWIF 以企业 WIF 登录结果写入账户,记认证方式与 provider +func (s *Store) AddWIF(ctx context.Context, login *antigravity.WIFLogin, device antigravity.Device) (*Account, error) { + return s.add(ctx, login.Token, identity{device: device, method: antigravity.AuthMethodEnterprise, provider: login.Provider}, s.proxy, func(*Account) {}) +} + +// identity 为一次登录或导入的账户身份:email 为已知的邮箱,device 为登录时的设备档案,method 与 provider 为认证方式与 WIF provider +type identity struct { + email string + device antigravity.Device + method string + provider string +} + +// add 以邮箱为 ID 写入 token 与资料:邮箱为空时取 id_token 载荷,再没有时以该认证方式的 OAuth 客户端经 proxy 请求 userinfo;设备档案带 installation_id 时用它补齐用户名与目录,否则新账户生成设备档案;已有账户改用本次的认证方式与 provider、保留其余字段,refresh token 变化时清空项目,服务据此按登录序列初始化;写入前以 change 修改账户;项目、套餐与地区由服务初始化账户时写回 +func (s *Store) add(ctx context.Context, token *oauth2.Token, from identity, proxy antigravity.Proxy, change func(*Account)) (*Account, error) { + email := cmp.Or(from.email, tokenEmail(token)) if email == "" { - source := antigravity.NewTokenSource(token, antigravity.AuthMethodConsumer, s.proxy, func(refreshed *oauth2.Token) error { - latest = refreshed + source := antigravity.NewTokenSource(token, from.method, from.provider, proxy, func(refreshed *oauth2.Token) error { + token = refreshed return nil }) - user, err := antigravity.NewClient("", source, antigravity.NewDevice(""), s.proxy).Userinfo(ctx) + user, err := antigravity.NewClient("", source, antigravity.NewDevice(""), proxy).Userinfo(ctx) if err != nil { return nil, err } email = user.Email } profile := antigravity.NewDevice(email) - if device.InstallationID != "" { - profile = device.WithAccount(email) + if from.device.InstallationID != "" { + profile = from.device.WithAccount(email) } - account := &Account{ID: email, Email: email, Device: profile, Enabled: true, AddedAt: time.Now()} s.mu.Lock() + defer s.mu.Unlock() + account := Account{ID: email, Email: email, Device: profile, Enabled: true, AddedAt: time.Now()} if existing, ok := s.accounts[email]; ok { - account = &existing - account.AuthMethod, account.WIFProvider = "", "" - } - s.mu.Unlock() - if err := s.SaveToken(account.ID, latest); err != nil { - return nil, err - } - if err := s.Save(account); err != nil { - return nil, err + account = existing + if s.tokens[email].RefreshToken != token.RefreshToken { + account.Project = "" + } } - return account, nil -} - -// AddWIF 以企业 WIF 登录结果写入账户,设备档案补齐用户名与目录,记认证方式与 provider -func (s *Store) AddWIF(ctx context.Context, login *antigravity.WIFLogin, device antigravity.Device) (*Account, error) { - account, err := s.AddWithDevice(ctx, login.Token, device) - if err != nil { + account.AuthMethod, account.WIFProvider = from.method, from.provider + change(&account) + if err := s.saveToken(email, token); err != nil { return nil, err } - account.AuthMethod, account.WIFProvider = antigravity.AuthMethodEnterprise, login.Provider - if err := s.Save(account); err != nil { + if err := s.save(&account); err != nil { return nil, err } - return account, nil + return &account, nil } // tokenEmail 返回 token 响应中 id_token 载荷的邮箱,登录换取的 token 带 id_token,官方客户端保存的 token 没有 @@ -315,7 +325,10 @@ func readJSON(path string, out any) error { if err != nil { return err } - return json.Unmarshal(data, out) + if err := json.Unmarshal(data, out); err != nil { + return fmt.Errorf("%s: %w", path, err) + } + return nil } // writeJSON 先写同目录临时文件再替换目标,保证读者只看到完整文件 diff --git a/internal/auth/transfer.go b/internal/auth/transfer.go index 9a3dfb1..bcab990 100644 --- a/internal/auth/transfer.go +++ b/internal/auth/transfer.go @@ -2,15 +2,18 @@ package auth import ( "cmp" + "context" "encoding/json" "regexp" "slices" "strings" + "golang.org/x/oauth2" + "github.com/Mag1cFall/Antigravity2API/internal/antigravity" ) -// Transfer 是账户 JSON 中的一个账户:Email 为文件中写的邮箱,备注名、代理、优先级、启用、AI 积分与设备档案取自本服务导出的文件(带 device.installation_id) +// Transfer 是账户 JSON 中的一个账户:Email 为文件中写的邮箱,备注名、代理、优先级、启用、AI 积分、认证方式、WIF provider 与设备档案取自本服务导出的文件(带 device.installation_id) type Transfer struct { Email string `json:"email"` RefreshToken string `json:"refresh_token"` @@ -19,6 +22,8 @@ type Transfer struct { Priority int `json:"priority,omitempty"` Enabled *bool `json:"enabled,omitempty"` AICredits bool `json:"ai_credits,omitempty"` + AuthMethod string `json:"auth_method,omitempty"` + WIFProvider string `json:"wif_provider,omitempty"` Device antigravity.Device `json:"device"` } @@ -34,11 +39,24 @@ func (s *Store) Export(ids []string) []Transfer { continue } enabled := account.Enabled - items = append(items, Transfer{Email: account.Email, RefreshToken: token.RefreshToken, Label: account.Label, Proxy: account.Proxy, Priority: account.Priority, Enabled: &enabled, AICredits: account.AICredits, Device: account.Device}) + items = append(items, Transfer{Email: account.Email, RefreshToken: token.RefreshToken, Label: account.Label, Proxy: account.Proxy, Priority: account.Priority, Enabled: &enabled, AICredits: account.AICredits, AuthMethod: account.AuthMethod, WIFProvider: account.WIFProvider, Device: account.Device}) } return items } +// Import 以导入项的 refresh token 写入账户,换取 token 与读取邮箱经 proxy 发出;本服务导出的项沿用其认证方式与 WIF provider,并写入备注名、代理、优先级、启用状态与 AI 积分设置 +func (s *Store) Import(ctx context.Context, item Transfer, proxy antigravity.Proxy) (*Account, error) { + return s.add(ctx, &oauth2.Token{RefreshToken: item.RefreshToken}, identity{device: item.Device, method: item.AuthMethod, provider: item.WIFProvider}, proxy, func(account *Account) { + if item.Device.InstallationID == "" { + return + } + account.Label, account.Proxy, account.Priority, account.AICredits = item.Label, item.Proxy, item.Priority, item.AICredits + if item.Enabled != nil { + account.Enabled = *item.Enabled + } + }) +} + // ParseTransfers 从账户 JSON 中取出账户,按 refresh token 去重:任意层级中带 refresh_token、refreshToken 或 token.refresh_token 的对象各为一个账户(本服务与 Antigravity Manager 的导出、CLIProxyAPI 与 gcli2api 的账户文件、opencode-antigravity-auth 与 antigravity-claude-proxy 的 accounts.json、sub2api 的数据导出),platform 不是 antigravity 的对象跳过,refreshToken 中 “|” 之后的项目 ID 去掉;内容不是 JSON 时取文本中的全部 refresh token func ParseTransfers(data []byte) []Transfer { var root any diff --git a/internal/service/service.go b/internal/service/service.go index f98f3d1..396dd8a 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -319,7 +319,17 @@ func usableQuota(groups []antigravity.QuotaGroup, catalog []antigravity.CatalogM return usable } -// Refresh 为还没有额度读取的账户取账本中 7 天内最近一次额度摘要,结束时结束启动期间的等待,初始化尚未就绪、账户代理已变或重新登录过的启用账户:相邻两个账户的初始化相隔随机 1 到 3 秒开始、彼此并发,全部结束后返回;停用或已删除的账户移出调度 +// pace 等待相邻两个账户的初始化之间的随机 1 到 3 秒间隔,ctx 结束时返回其错误 +func pace(ctx context.Context) error { + select { + case <-time.After(time.Second + rand.N(2*time.Second)): + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// Refresh 为还没有额度读取的账户取账本中 7 天内最近一次额度摘要,结束时结束启动期间的等待,初始化尚未就绪、账户代理已变或重新登录过的启用账户:相邻两个账户的初始化相隔 pace 开始、彼此并发,全部结束后返回;停用或已删除的账户移出调度 func (s *Service) Refresh(ctx context.Context) error { defer s.markLaunched() records := s.store.Accounts() @@ -350,29 +360,25 @@ func (s *Service) Refresh(ctx context.Context) error { waited := false for _, record := range records { known[record.ID] = true - if !record.Enabled { + token := s.store.Token(record.ID) + if !record.Enabled || token == nil { continue } enabled[record.ID] = true s.pool.Profile(record.ID, record.Priority, record.Rank()) s.pool.Credits(record.ID, record.AICredits) - current := s.account(record.ID) - refresh := s.store.Token(record.ID).RefreshToken - if current != nil && current.proxy == record.Proxy && current.refresh == refresh { + if current := s.account(record.ID); current != nil && current.proxy == record.Proxy && current.refresh == token.RefreshToken { continue } if waited { - select { - case <-time.After(time.Second + rand.N(2*time.Second)): - case <-ctx.Done(): + if err := pace(ctx); err != nil { started.Wait() - return errors.Join(append(errs, ctx.Err())...) + return errors.Join(append(errs, err)...) } } waited = true - login := record.Project == "" || current != nil && current.refresh != refresh started.Go(func() { - err := s.initialize(ctx, record, login) + err := s.initialize(ctx, record) collect.Lock() errs = append(errs, err) collect.Unlock() @@ -402,15 +408,13 @@ func (s *Service) Refresh(ctx context.Context) error { // Verify 重新初始化一个启用账户,需要验证或初始化失败的账户由此恢复调度 func (s *Service) Verify(ctx context.Context, id string) error { - for _, record := range s.store.Accounts() { - if record.ID == id && record.Enabled { - return s.initialize(ctx, record, record.Project == "") - } + if record, ok := s.store.Account(id); ok && record.Enabled { + return s.initialize(ctx, &record) } return fmt.Errorf("账户 %s 不存在或已停用", id) } -// RetryFailed 依次重新初始化启用中但初始化失败、认证失败或需要验证的账户,相邻两次之间随机间隔 1–3 秒,返回重试的账户数与仍失败的账户数 +// RetryFailed 依次重新初始化启用中但初始化失败、认证失败或需要验证的账户,相邻两次之间相隔 pace,返回重试的账户数与仍失败的账户数 func (s *Service) RetryFailed(ctx context.Context) (int, int) { s.mu.Lock() var failed []auth.Account @@ -422,14 +426,10 @@ func (s *Service) RetryFailed(ctx context.Context) (int, int) { s.mu.Unlock() remaining := 0 for index, record := range failed { - if index > 0 { - select { - case <-time.After(time.Second + rand.N(2*time.Second)): - case <-ctx.Done(): - return index, remaining - } + if index > 0 && pace(ctx) != nil { + return index, remaining } - if s.initialize(ctx, &record, record.Project == "") != nil { + if s.initialize(ctx, &record) != nil { remaining++ } } @@ -456,6 +456,18 @@ func (s *Service) proxy(record *auth.Account) antigravity.Proxy { return func(*http.Request) (*url.URL, error) { return proxy, err } } +// Import 把账户 JSON 中的一个账户写入账户库,换取 token 与读取邮箱经该账户的代理发出:导入项带代理时用它,否则用代理池按项中邮箱绑定的代理;项中邮箱不是账户库中的账户时解除它的绑定 +func (s *Service) Import(ctx context.Context, item auth.Transfer) (*auth.Account, error) { + if err := config.ValidateProxy(item.Proxy); err != nil && item.Proxy != proxypool.Direct { + return nil, err + } + account, err := s.store.Import(ctx, item, s.proxy(&auth.Account{ID: item.Email, Proxy: item.Proxy})) + if _, known := s.store.Account(item.Email); !known { + s.proxies.Release(item.Email) + } + return account, err +} + // assisted 返回按 loadCodeAssist 响应更新项目、当前套餐、付费套餐与 Google One AI 积分余额后的账户 func assisted(record auth.Account, assist antigravity.LoadCodeAssistResponse) auth.Account { record.Project, record.Tier, record.PaidTier = assist.CloudAICompanionProject, assist.CurrentTier.ID, "" @@ -472,23 +484,28 @@ func assisted(record auth.Account, assist antigravity.LoadCodeAssistResponse) au } // tokenSource 返回账户保存的 token 的来源,刷新后写回账户库 -func (s *Service) tokenSource(record *auth.Account, proxy antigravity.Proxy) *antigravity.TokenSource { - return antigravity.NewTokenSource(s.store.Token(record.ID), record.AuthMethod, proxy, func(token *oauth2.Token) error { +func (s *Service) tokenSource(record *auth.Account, token *oauth2.Token, proxy antigravity.Proxy) *antigravity.TokenSource { + return antigravity.NewTokenSource(token, record.AuthMethod, record.WIFProvider, proxy, func(token *oauth2.Token) error { slog.Info(fmt.Sprintf("token 已刷新 | 到期=%s", token.Expiry.Format(time.RFC3339)), "source", record.ID) return s.store.UpdateToken(record.ID, token) }) } -// initialize 以账户的设备档案与代理开始虚拟客户端,login 为真时按登录序列、否则按冷启动序列初始化,读到的套餐、项目、地区、目录与额度经 observe 写回(冷启动不读额度,沿用最近一次读取),成功且账户仍在账户库中启用时加入调度并开始伴随请求;失败时停止伴随请求并把账户移出调度,需要验证时原因带上游错误正文中的验证链接 -func (s *Service) initialize(ctx context.Context, record *auth.Account, login bool) error { +// initialize 以账户的设备档案与代理开始虚拟客户端,项目为空或 refresh token 与调度中的客户端不同时按登录序列、否则按冷启动序列初始化,读到的套餐、项目、地区、目录与额度经 observe 写回(冷启动不读额度,沿用最近一次读取),成功且账户仍在账户库中启用时加入调度并开始伴随请求;失败时停止伴随请求并把账户移出调度,需要验证时原因带上游错误正文中的验证链接;账户已从账户库删除时不初始化 +func (s *Service) initialize(ctx context.Context, record *auth.Account) error { token := s.store.Token(record.ID) + if token == nil { + return nil + } proxy := s.proxy(record) - source := s.tokenSource(record, proxy) + source := s.tokenSource(record, token, proxy) client := antigravity.NewClient(record.ID, source, record.Device, proxy) client.Observe(s.observe(record.ID, client)) path := s.hubPath(record.ID) client.SetHub(loadHub(path), saveHub(path)) watch, cancel := context.WithCancel(context.Background()) + running := s.account(record.ID) + login := record.Project == "" || running != nil && running.refresh != token.RefreshToken companion, initialization, err := boot(ctx, watch, client, record.ID, login, record.AuthMethod == antigravity.AuthMethodEnterprise) s.mu.Lock() defer s.mu.Unlock() @@ -603,13 +620,8 @@ const quotaWorkers = 5 // RefreshQuota 实时读取一个账户的套餐与额度摘要:已初始化的账户经自己的客户端读取,其余账户(含停用)以保存的 token、设备档案与代理读取;先读套餐与项目(已初始化的账户从当前 cloudcode 端点,未初始化的企业 WIF 账户按 BusinessAssist,其余为 daily loadCodeAssist),读到的套餐与额度经 observe 写回 func (s *Service) RefreshQuota(ctx context.Context, id string) error { - var record *auth.Account - for _, item := range s.store.Accounts() { - if item.ID == id { - record = item - } - } - if record == nil { + record, ok := s.store.Account(id) + if !ok { return fmt.Errorf("账户 %s 不存在", id) } var client *antigravity.Client @@ -618,8 +630,8 @@ func (s *Service) RefreshQuota(ctx context.Context, id string) error { client = current.client _, err = client.Assist(ctx) } else { - proxy := s.proxy(record) - client = antigravity.NewClient(id, s.tokenSource(record, proxy), record.Device, proxy) + proxy := s.proxy(&record) + client = antigravity.NewClient(id, s.tokenSource(&record, s.store.Token(id), proxy), record.Device, proxy) client.Observe(s.observe(id, client)) if record.AuthMethod == antigravity.AuthMethodEnterprise { _, err = client.BusinessAssist(ctx) diff --git a/web/src/features/accounts/actions.ts b/web/src/features/accounts/actions.ts index 5292e4b..8ae86df 100644 --- a/web/src/features/accounts/actions.ts +++ b/web/src/features/accounts/actions.ts @@ -429,6 +429,14 @@ export function useSwitchAccount() { // pendingRemovals 是撤销期内尚未发出删除的账户及取消该删除的函数 const pendingRemovals = new Map void>() +// 页面关闭或刷新时立即发出撤销期内尚未发出的删除,并取消计时器中的同一次删除 +window.addEventListener('pagehide', () => { + for (const [id, keep] of pendingRemovals) { + void fetch(accountPath(id), { method: 'DELETE', keepalive: true }) + keep() + } +}) + // keepAccounts 取消这些账户在撤销期内待发出的删除并恢复显示,账户被重新导入或登录时调用 export function keepAccounts(ids: readonly string[]) { for (const id of ids) pendingRemovals.get(id)?.()