diff --git a/docs/protocol.md b/docs/protocol.md index dc1dabdc..2106369f 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -1155,8 +1155,8 @@ server content 的 index `0/1/2/4/5/6` 分别为 model content、turn complete | `AdminAccount` | `id`、`label`、`enabled`、`state`、`proxy`、`locale`、`timezone`、`models`、`benefit_tier`、`message` | | `AccountCreateInput` | `proxy`、`locale`、`timezone` | | `AccountInput` | `label`、`enabled`、`proxy`、`locale`、`timezone` | -| `ChromeImportProfile` | `profile`、`display_name`、`email`、`locale` | -| `ChromeImportInput` | `profiles`、`proxy`、`locale`、`timezone` | +| `ChromeImportProfile` | `id`、`profile`、`display_name`、`email`、`locale` | +| `ChromeImportInput` | `account_ids`、`profiles`、`proxy`、`locale`、`timezone` | | `AdminCooldown` | `account_id`、`account_label`、`model_id`、`until`、可选 `reason` | | `AdminRequest` | `id`、`model`、`account_id`、`account_label`、`state`、`started_at` | | `AdminLog` | `time`、`level`、`source`、`message`、`event`;请求事件携带 `request`,包含 `id`、`state`、HTTP `status`、`model`、`duration_ms`、`tool_calls`、`usage` 与诊断字段,字段口径见 [logging.md](logging.md) | @@ -1164,6 +1164,8 @@ server content 的 index `0/1/2/4/5/6` 分别为 model content、turn complete `AdminStatus.state` 为 `STOPPED`、`LAUNCHING` 或 `RUNNING`;`running` 只在 `RUNNING` 为 true;`ready` 要求 `RUNNING` 且至少一个账户处于 ready 或 busy;`version` 来自构建信息;`active_requests` 是当前进程请求注册表数量。`AdminAccount.message` 保存当前状态原因,`models` 是该账户实时目录 ID。`until` 与 `started_at` 使用 RFC 3339 JSON time。 +Chrome 导入列表按 `Preferences.account_info` 中的 Gaia ID 与邮箱逐个展示账号。同一 Profile 可以包含多个账号;管理页提交选中的 `account_ids`(Profile/Gaia ID),导入时精确查询对应的 `token_service.service`,不会回退到其他账号。列表默认不勾选。CLI 的 Profile 选择仍表示选择该 Profile 下的账号。 + `AccountCreateInput` 启动隔离 Camoufox 登录,邮箱由 AI Studio 页面读取。`ChromeImportInput.profiles` 可一次选择多个 Chrome Profile。`AccountInput.label` 必须与不可变的 Google 邮箱 ID 一致,`locale` 与 `timezone` 必须非空,`proxy` 使用无 credentials、path、query 或 fragment 的 HTTP、HTTPS、SOCKS5 origin。新增、导入、登录和验证成功后立即刷新该账户模型目录,并发布最新账户与模型事件。 `PUT /api/accounts/{id}` 的提交顺序固定为:校验不可变邮箱 ID,取得账户独占租约,创建未发布的新固定出口,关闭当前 Worker 并把新 Worker 配置标记为 `pending`(尚未发布),在模型目录写锁内原子写入 `account.json` 并更新账户池,随后发布 Worker 配置、替换固定出口、释放租约并重建模型缓存。`account.json` 写入是唯一持久提交点。提交前的出口创建、Worker 关闭或写入错误会丢弃这份待发布配置并保持旧配置;已经关闭的 Worker 由后续请求按旧配置重建。持久写入后,新配置、Worker 配置与固定出口共同成为已提交状态。租约释放错误保留该提交状态并返回原始 unlock 错误;释放成功后记录完成日志并同步模型缓存。 diff --git a/internal/api/admin.go b/internal/api/admin.go index b362b141..af082881 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -155,6 +155,7 @@ type AccountCreateInput struct { // ChromeImportProfile 表示可从本机 Chrome 导入的账号 type ChromeImportProfile struct { + ID string `json:"id"` Profile string `json:"profile"` DisplayName string `json:"display_name"` Email string `json:"email"` @@ -163,10 +164,11 @@ type ChromeImportProfile struct { // ChromeImportInput 表示批量导入的 Chrome Profile 与账户环境 type ChromeImportInput struct { - Profiles []string `json:"profiles"` - Proxy string `json:"proxy"` - Locale string `json:"locale"` - Timezone string `json:"timezone"` + AccountIDs []string `json:"account_ids"` + Profiles []string `json:"profiles"` + Proxy string `json:"proxy"` + Locale string `json:"locale"` + Timezone string `json:"timezone"` } // RuntimeConfig 表示全局运行配置 diff --git a/internal/app/admin.go b/internal/app/admin.go index c0773dfb..a0ac834d 100644 --- a/internal/app/admin.go +++ b/internal/app/admin.go @@ -224,14 +224,14 @@ func (admin *runtimeAdmin) ChromeImportProfiles(context.Context) ([]api.ChromeIm continue } profiles = append(profiles, api.ChromeImportProfile{ - Profile: account.Profile, DisplayName: account.DisplayName, Email: email, Locale: account.Locale, + ID: account.ID, Profile: account.Profile, DisplayName: account.DisplayName, Email: email, Locale: account.Locale, }) } return profiles, nil } func (admin *runtimeAdmin) ImportChromeAccounts(ctx context.Context, input api.ChromeImportInput) ([]api.AdminAccount, error) { - if len(input.Profiles) == 0 { + if len(input.Profiles) == 0 && len(input.AccountIDs) == 0 { return nil, invalidAccount(fmt.Errorf("未选择 Chrome 账号")) } root, err := chromeauth.DefaultChromeRoot() @@ -240,7 +240,7 @@ func (admin *runtimeAdmin) ImportChromeAccounts(ctx context.Context, input api.C } accountProxy := strings.TrimSpace(input.Proxy) results, err := chromeauth.Import(ctx, chromeauth.ImportOptions{ - ChromeRoot: root, Proxy: admin.effectiveProxy(accountProxy), Profiles: input.Profiles, + ChromeRoot: root, Proxy: admin.effectiveProxy(accountProxy), Profiles: input.Profiles, AccountIDs: input.AccountIDs, }) if err != nil { return nil, err diff --git a/internal/chromeauth/auth.go b/internal/chromeauth/auth.go index 24f3cba5..6a68c529 100644 --- a/internal/chromeauth/auth.go +++ b/internal/chromeauth/auth.go @@ -20,6 +20,8 @@ const ( // Account 描述本机 Chrome 中可发现的 Google 账号 type Account struct { + ID string `json:"id"` + GaiaID string `json:"-"` Profile string `json:"profile"` DisplayName string `json:"display_name"` Email string `json:"email"` @@ -29,6 +31,7 @@ type Account struct { // ImportOptions 保存 Chrome 批量导入参数 type ImportOptions struct { + AccountIDs []string ChromeRoot string Proxy string Profiles []string @@ -68,7 +71,7 @@ func Import(ctx context.Context, options ImportOptions) ([]ImportResult, error) if err != nil { return nil, err } - selected, err := selectAccounts(accounts, options.Profiles, options.Emails) + selected, err := selectAccounts(accounts, options.Profiles, options.Emails, options.AccountIDs) if err != nil { return nil, err } @@ -107,7 +110,7 @@ func Refresh(ctx context.Context, material aistudio.ChromeOAuthMaterial, proxy s } func importAccount(ctx context.Context, chromeRoot string, proxy string, account Account, masterKey []byte) (ImportResult, error) { - gaiaID, encryptedToken, wrappedKey, err := readTokenService(chromeRoot, account.Profile) + gaiaID, encryptedToken, wrappedKey, err := readTokenService(chromeRoot, account.Profile, account.GaiaID) if err != nil { return ImportResult{}, err } @@ -136,13 +139,15 @@ func importAccount(ctx context.Context, chromeRoot string, proxy string, account }, nil } -func selectAccounts(accounts []Account, profiles []string, emails []string) ([]Account, error) { +func selectAccounts(accounts []Account, profiles []string, emails []string, ids []string) ([]Account, error) { + requestedIDs := normalizedSet(ids) requestedProfiles := normalizedSet(profiles) requestedEmails := normalizedSet(emails) - if len(requestedProfiles) == 0 && len(requestedEmails) == 0 { + if len(requestedProfiles) == 0 && len(requestedEmails) == 0 && len(requestedIDs) == 0 { return nil, fmt.Errorf("未选择 Chrome 账号") } selected := make([]Account, 0, len(accounts)) + foundIDs := make(map[string]struct{}) foundProfiles := make(map[string]struct{}) foundEmails := make(map[string]struct{}) for _, account := range accounts { @@ -150,7 +155,8 @@ func selectAccounts(accounts []Account, profiles []string, emails []string) ([]A email := strings.ToLower(strings.TrimSpace(account.Email)) _, profileMatch := requestedProfiles[profile] _, emailMatch := requestedEmails[email] - if !profileMatch && !emailMatch { + _, idMatch := requestedIDs[strings.ToLower(account.ID)] + if !profileMatch && !emailMatch && !idMatch { continue } if !account.Importable { @@ -160,9 +166,13 @@ func selectAccounts(accounts []Account, profiles []string, emails []string) ([]A return nil, fmt.Errorf("%s 缺少账号邮箱", account.Profile) } selected = append(selected, account) + foundIDs[strings.ToLower(account.ID)] = struct{}{} foundProfiles[profile] = struct{}{} foundEmails[email] = struct{}{} } + if missing := missingValues(requestedIDs, foundIDs); len(missing) != 0 { + return nil, fmt.Errorf("找不到选中的 Chrome 账号") + } if missing := missingValues(requestedProfiles, foundProfiles); len(missing) != 0 { return nil, fmt.Errorf("找不到 Chrome Profile: %s", strings.Join(missing, ", ")) } diff --git a/internal/chromeauth/platform_other.go b/internal/chromeauth/platform_other.go index 99a51a6a..fd1faedb 100644 --- a/internal/chromeauth/platform_other.go +++ b/internal/chromeauth/platform_other.go @@ -16,6 +16,6 @@ func discoverPlatform(string) ([]Account, error) { return nil, fmt.Errorf("Chrome OAuth 导入仅支持 Windows") } -func readTokenService(string, string) (string, []byte, []byte, error) { +func readTokenService(string, string, string) (string, []byte, []byte, error) { return "", nil, nil, fmt.Errorf("Chrome OAuth 导入仅支持 Windows") } diff --git a/internal/chromeauth/platform_windows.go b/internal/chromeauth/platform_windows.go index ea89cfe0..c76da9cd 100644 --- a/internal/chromeauth/platform_windows.go +++ b/internal/chromeauth/platform_windows.go @@ -65,11 +65,34 @@ func discoverPlatform(chromeRoot string) ([]Account, error) { if locale == "" { locale = state.Variations.SafeSeedLocale } - _, encrypted, bindingKey, tokenErr := readTokenService(chromeRoot, profile) - accounts = append(accounts, Account{ - Profile: profile, DisplayName: info.Name, Email: info.UserName, Locale: locale, - Importable: tokenErr == nil && strings.HasPrefix(string(encrypted), "v20") && len(bindingKey) != 0, - }) + // Preferences maps every Gaia identity to its own email; profile.user_name + // only describes the primary Chrome account and cannot label secondary tokens. + metadata, err := os.ReadFile(filepath.Join(chromeRoot, profile, "Preferences")) + if err != nil { + return nil, fmt.Errorf("读取 %s Preferences: %w", profile, err) + } + var preferences struct { + Accounts []struct { + Gaia string `json:"gaia"` + Email string `json:"email"` + } `json:"account_info"` + } + if err := json.Unmarshal(metadata, &preferences); err != nil { + return nil, fmt.Errorf("解析 %s Preferences: %w", profile, err) + } + seen := map[string]bool{} + for _, identity := range preferences.Accounts { + if identity.Gaia == "" || seen[identity.Gaia] { + continue + } + seen[identity.Gaia] = true + _, encrypted, bindingKey, tokenErr := readTokenService(chromeRoot, profile, identity.Gaia) + accounts = append(accounts, Account{ + ID: profile + "/" + identity.Gaia, GaiaID: identity.Gaia, + Profile: profile, DisplayName: info.Name, Email: identity.Email, Locale: locale, + Importable: tokenErr == nil && strings.HasPrefix(string(encrypted), "v20") && len(bindingKey) != 0, + }) + } } return accounts, nil } @@ -96,7 +119,7 @@ func profileLocale(chromeRoot string, profile string) string { return "" } -func readTokenService(chromeRoot string, profile string) (string, []byte, []byte, error) { +func readTokenService(chromeRoot string, profile string, gaiaID string) (string, []byte, []byte, error) { databasePath := filepath.Join(chromeRoot, profile, "Web Data") uri := "file:" + filepath.ToSlash(databasePath) + "?mode=ro&immutable=1" database, err := sql.Open("sqlite", uri) @@ -106,7 +129,7 @@ func readTokenService(chromeRoot string, profile string) (string, []byte, []byte defer database.Close() database.SetMaxOpenConns(1) - rows, err := database.Query("SELECT service, encrypted_token, binding_key FROM token_service") + rows, err := database.Query("SELECT service, encrypted_token, binding_key FROM token_service WHERE service = ?", "AccountId-"+gaiaID) if err != nil { return "", nil, nil, fmt.Errorf("读取 %s token_service: %w", profile, err) } diff --git a/internal/chromeauth/platform_windows_test.go b/internal/chromeauth/platform_windows_test.go new file mode 100644 index 00000000..d02f1e5c --- /dev/null +++ b/internal/chromeauth/platform_windows_test.go @@ -0,0 +1,60 @@ +//go:build windows + +package chromeauth + +import ( + "database/sql" + "os" + "path/filepath" + "testing" +) + +func TestDiscoverAndReadMultipleAccounts(t *testing.T) { + root := t.TempDir() + os.MkdirAll(filepath.Join(root, "Default"), 0700) + os.WriteFile(filepath.Join(root, "Local State"), []byte(`{"profile":{"info_cache":{"Default":{"name":"Fixture","user_name":"first@example.com"}}}}`), 0600) + os.WriteFile(filepath.Join(root, "Default", "Preferences"), []byte(`{"account_info":[{"gaia":"111","email":"first@example.com"},{"gaia":"222","email":"second@example.com"},{"gaia":"333","email":"missing@example.com"}],"intl":{"accept_languages":"zh-CN"}}`), 0600) + db, err := sql.Open("sqlite", filepath.Join(root, "Default", "Web Data")) + if err != nil { + t.Fatal(err) + } + if _, err = db.Exec("CREATE TABLE token_service(service TEXT PRIMARY KEY, encrypted_token BLOB, binding_key BLOB)"); err != nil { + t.Fatal(err) + } + for _, id := range []string{"111", "222"} { + if _, err = db.Exec("INSERT INTO token_service VALUES(?,?,?)", "AccountId-"+id, []byte("v20-fixture-"+id), []byte("binding-"+id)); err != nil { + t.Fatal(err) + } + } + db.Close() + accounts, err := Discover(root) + if err != nil { + t.Fatal(err) + } + if len(accounts) != 3 { + t.Fatalf("got %d accounts", len(accounts)) + } + if !accounts[0].Importable || !accounts[1].Importable || accounts[2].Importable { + t.Fatal("incorrect per-account eligibility") + } + selected, err := selectAccounts(accounts, nil, nil, []string{"Default/222"}) + if err != nil { + t.Fatal(err) + } + if len(selected) != 1 || selected[0].Email != "second@example.com" { + t.Fatal("selected wrong account") + } + gaia, token, binding, err := readTokenService(root, selected[0].Profile, selected[0].GaiaID) + if err != nil || gaia != "222" || string(token) != "v20-fixture-222" || string(binding) != "binding-222" { + t.Fatal("credentials do not match selection") + } + if _, _, _, err = readTokenService(root, "Default", "999"); err == nil { + t.Fatal("missing identity must fail, not fall back to another account") + } + if _, err = selectAccounts(accounts, nil, nil, []string{"Default/999"}); err == nil { + t.Fatal("stale selection must fail") + } + if _, err = selectAccounts(accounts, nil, nil, []string{"Default/333"}); err == nil { + t.Fatal("missing credentials must fail") + } +} diff --git a/web/src/components/AccountsPanel.vue b/web/src/components/AccountsPanel.vue index 2c3cd2fa..f558b33e 100644 --- a/web/src/components/AccountsPanel.vue +++ b/web/src/components/AccountsPanel.vue @@ -97,7 +97,7 @@ async function openChromeImport(): Promise { pendingAction.value = 'chrome-discover' try { chromeProfiles.value = await api.chromeImportProfiles() - selectedChromeProfiles.value = chromeProfiles.value.map((profile) => profile.profile) + selectedChromeProfiles.value = [] showChromeImport.value = true } catch (error) { actionError(error) @@ -118,7 +118,7 @@ async function importChromeAccounts(): Promise { pendingAction.value = 'chrome-import' try { const result = await api.importChromeAccounts({ - profiles: [...selectedChromeProfiles.value], + account_ids: [...selectedChromeProfiles.value], ...accountEnvironment, }) showChromeImport.value = false @@ -561,14 +561,14 @@ async function removeAccount(account: Account): Promise {