Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -1155,15 +1155,17 @@ 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) |
| `AdminEvent` | `type`、`data` |

`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 错误;释放成功后记录完成日志并同步模型缓存。
Expand Down
10 changes: 6 additions & 4 deletions internal/api/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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 表示全局运行配置
Expand Down
6 changes: 3 additions & 3 deletions internal/app/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down
20 changes: 15 additions & 5 deletions internal/chromeauth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -29,6 +31,7 @@ type Account struct {

// ImportOptions 保存 Chrome 批量导入参数
type ImportOptions struct {
AccountIDs []string
ChromeRoot string
Proxy string
Profiles []string
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -136,21 +139,24 @@ 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 {
profile := strings.ToLower(strings.TrimSpace(account.Profile))
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 {
Expand All @@ -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, ", "))
}
Expand Down
2 changes: 1 addition & 1 deletion internal/chromeauth/platform_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
37 changes: 30 additions & 7 deletions internal/chromeauth/platform_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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)
Expand All @@ -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)
}
Expand Down
60 changes: 60 additions & 0 deletions internal/chromeauth/platform_windows_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
8 changes: 4 additions & 4 deletions web/src/components/AccountsPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ async function openChromeImport(): Promise<void> {
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)
Expand All @@ -118,7 +118,7 @@ async function importChromeAccounts(): Promise<void> {
pendingAction.value = 'chrome-import'
try {
const result = await api.importChromeAccounts({
profiles: [...selectedChromeProfiles.value],
account_ids: [...selectedChromeProfiles.value],
...accountEnvironment,
})
showChromeImport.value = false
Expand Down Expand Up @@ -561,14 +561,14 @@ async function removeAccount(account: Account): Promise<void> {
<div v-else class="max-h-[50vh] space-y-2 overflow-auto">
<label
v-for="profile in chromeProfiles"
:key="profile.profile"
:key="profile.id"
class="flex cursor-pointer items-start gap-3 rounded border border-[#30363d] bg-[#0d1117] p-3 transition hover:border-[#4b5563]"
>
<input
v-model="selectedChromeProfiles"
class="mt-1 h-4 w-4 shrink-0 accent-blue-600"
type="checkbox"
:value="profile.profile"
:value="profile.id"
/>
<span class="min-w-0 flex-1">
<strong class="block truncate text-sm text-white">{{ profile.email }}</strong>
Expand Down
3 changes: 2 additions & 1 deletion web/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,15 @@ export interface AccountLoginInput {
}

export interface ChromeImportProfile {
id: string
profile: string
display_name: string
email: string
locale: string
}

export interface ChromeImportInput extends AccountLoginInput {
profiles: string[]
account_ids: string[]
}

export interface AccountCounters {
Expand Down