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
7 changes: 7 additions & 0 deletions changelog/unreleased/workbuddy-accounts-import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### English

- Fix WorkBuddy credential import camelCase decode; accept hub `workbuddy-accounts` export packs and import multiple accounts in one paste (partial success with success/fail summary).

### 中文

- 修好 WorkBuddy 凭证 camelCase 解码;支持 hub `workbuddy-accounts` 整包粘贴批量导入(尽量导入成功项,汇总成功/失败)。
44 changes: 39 additions & 5 deletions frontend/src/components/AddAccountModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -455,18 +455,26 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
setMessage(t('wizardBadJson'))
return
}
// Hub multi-account packages keep workbuddy-accounts. Do not rewrite them
// to oauth-v1 or only the first entry would import.
const isWorkbuddyAccounts =
activeOption.provider === 'workbuddy' &&
(bundle.format === 'workbuddy-accounts' || Array.isArray(bundle.accounts))
if (!bundle.format) {
if (activeOption.descriptor.id === 'workbuddy') bundle.format = 'workbuddy-oauth-v1'
if (isWorkbuddyAccounts) bundle.format = 'workbuddy-accounts'
else if (activeOption.descriptor.id === 'workbuddy') bundle.format = 'workbuddy-oauth-v1'
else if (activeOption.descriptor.id === 'trae') bundle.format = 'trae-oauth-v1'
else if (activeOption.descriptor.id === 'devin') bundle.format = 'devin-session-v1'
else if (activeOption.descriptor.id === 'command') bundle.format = 'command-key-v1'
else if (activeOption.descriptor.id === 'qwenwork') bundle.format = 'qwenwork-oauth-v1'
else bundle.format = 'qoder-native-v1'
} else if (isWorkbuddyAccounts) {
bundle.format = 'workbuddy-accounts'
}
try {
setPhase('busy')
const options = accountOptions()
await importAccount({
const result = await importAccount({
...bundle,
name: name.trim() || bundle.name,
enabled: true,
Expand All @@ -475,11 +483,37 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
max_inflight: options.max_inflight,
priority: options.priority,
drop_system_prompt: options.drop_system_prompt,
})
}) as {
object?: string
succeeded?: number
failed?: number
errors?: Array<{ index?: number; name?: string; message?: string }>
id?: string
}
const succeeded = typeof result?.succeeded === 'number'
? result.succeeded
: (result?.id || result ? 1 : 0)
const failed = typeof result?.failed === 'number' ? result.failed : 0
const failureSummary = (result?.errors || [])
.slice(0, 3)
.map((item) => item.message || `index ${item.index}`)
.filter(Boolean)
.join('; ')
setPhase('done')
setMessage(t('accountImported'))
if (failed > 0) {
setMessage(
t('accountImportPartial')
.replace('{ok}', String(succeeded))
.replace('{fail}', String(failed))
+ (failureSummary ? ` — ${failureSummary}` : ''),
)
} else if (succeeded > 1) {
setMessage(t('accountImportBatch').replace('{n}', String(succeeded)))
} else {
setMessage(t('accountImported'))
}
onAdded()
window.setTimeout(finishAndClose, 700)
window.setTimeout(finishAndClose, failed > 0 ? 1600 : 700)
} catch (error) {
setPhase('idle')
setMessage(error instanceof Error ? error.message : String(error))
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,8 @@ export const messages: Record<Lang, Dict> = {
accountCreated: 'Account created. Session is starting.',
importCredential: 'Import native credential',
accountImported: 'Credential imported and runtime started.',
accountImportBatch: 'Imported {n} accounts.',
accountImportPartial: 'Imported {ok}, failed {fail}.',
credentialCopied: 'Credential bundle copied.',
addAccountTitle: 'Add account',
accountType: 'Account type',
Expand Down Expand Up @@ -854,7 +856,7 @@ export const messages: Record<Lang, Dict> = {
zhipuKeyRequired: 'Paste the Zhipu API key first.',
wizardPatPh: 'Paste personal access token',
wizardCreateAndLogin: 'Create and sign in',
wizardImportLead: 'Paste a credential bundle previously exported from this console.',
wizardImportLead: 'Paste a credential JSON export. WorkBuddy hub packages import every account in the file.',
wizardImportPh: 'Paste credential JSON',
wizardChooseFile: 'Choose file',
wizardBadJson: 'That does not look like a valid credential bundle.',
Expand Down Expand Up @@ -1660,6 +1662,8 @@ export const messages: Record<Lang, Dict> = {
accountCreated: '账号已创建,会话正在启动。',
importCredential: '导入原生凭证',
accountImported: '凭证已导入,运行时已启动。',
accountImportBatch: '已导入 {n} 个账号。',
accountImportPartial: '成功 {ok},失败 {fail}。',
credentialCopied: '凭证包已复制。',
addAccountTitle: '添加账号',
accountType: '账号类型',
Expand Down Expand Up @@ -1766,7 +1770,7 @@ export const messages: Record<Lang, Dict> = {
zhipuKeyRequired: '请先粘贴智谱 API Key。',
wizardPatPh: '粘贴个人访问令牌',
wizardCreateAndLogin: '创建并登录',
wizardImportLead: '粘贴此前从本控制台导出的凭证包。',
wizardImportLead: '粘贴凭证 JSON。WorkBuddy hub 导出包会一次性导入其中全部账号。',
wizardImportPh: '粘贴凭证 JSON',
wizardChooseFile: '选择文件',
wizardBadJson: '这看起来不是有效的凭证包。',
Expand Down
14 changes: 12 additions & 2 deletions internal/console/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,22 @@ func (h *Handler) HandleAccountImport(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
account, err := h.Control.Accounts.Import(r.Context(), input, raw)
result, err := h.Control.Accounts.ImportAll(r.Context(), input, raw)
if err != nil {
writeOperationError(w, err)
return
}
writeJSON(w, http.StatusCreated, account)
// Single-credential imports keep the historical account body so existing
// clients (and the add-account wizard) do not need a shape change.
if result.Succeeded == 1 && result.Failed == 0 && len(result.Data) == 1 {
writeJSON(w, http.StatusCreated, result.Data[0])
return
}
status := http.StatusCreated
if result.Failed > 0 {
status = http.StatusOK
}
writeJSON(w, status, result)
}

func (h *Handler) HandleAccountByID(w http.ResponseWriter, r *http.Request) {
Expand Down
151 changes: 135 additions & 16 deletions internal/control/account_import.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"strings"

"github.com/caigee-cmd/cli2api/internal/accounts"
"github.com/caigee-cmd/cli2api/internal/providers"
)
Expand All @@ -27,11 +30,46 @@ type AccountImportInput struct {
Credential json.RawMessage `json:"credential"`
}

// AccountImportItemError records one failed entry inside a multi-account package.
type AccountImportItemError struct {
Index int `json:"index"`
Name string `json:"name,omitempty"`
Message string `json:"message"`
}

// AccountImportResult is the import outcome. Single-credential imports still
// populate Data with one account; packages may partially succeed.
type AccountImportResult struct {
Object string `json:"object"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Data []accounts.Account `json:"data"`
Errors []AccountImportItemError `json:"errors,omitempty"`
}

func (a *Accounts) Import(ctx context.Context, input AccountImportInput, raw []byte) (accounts.Account, error) {
result, err := a.ImportAll(ctx, input, raw)
if err != nil {
return accounts.Account{}, err
}
if result.Succeeded == 0 {
msg := "account import failed"
if len(result.Errors) > 0 {
msg = result.Errors[0].Message
}
return accounts.Account{}, operationError("account_import_failed", msg)
}
return result.Data[0], nil
}

// ImportAll imports one credential or every entry in a multi-account package.
// Failure policy is partial success: valid entries are kept, failures are
// summarized in Errors instead of rolling back successes.
func (a *Accounts) ImportAll(ctx context.Context, input AccountImportInput, raw []byte) (AccountImportResult, error) {
if input.Format == "qoder-native-v1" {
blob, err := base64.StdEncoding.DecodeString(input.UserBlob)
if err != nil {
return accounts.Account{}, operationError("invalid_user_blob", "user_blob must be base64")
return AccountImportResult{}, operationError("invalid_user_blob", "user_blob must be base64")
}
account, err := a.ImportNative(ctx, accounts.ImportAccount{
AutoCheckin: input.AutoCheckin, CheckinTime: input.CheckinTime,
Expand All @@ -41,9 +79,9 @@ func (a *Accounts) Import(ctx context.Context, input AccountImportInput, raw []b
Credential: accounts.NativeCredential{UserBlob: blob, MachineID: input.MachineID},
})
if err != nil {
return account, operationError("account_import_failed", err.Error())
return AccountImportResult{}, operationError("account_import_failed", err.Error())
}
return account, nil
return AccountImportResult{Object: "import_result", Succeeded: 1, Data: []accounts.Account{account}}, nil
}
payload := input.Credential
if len(payload) == 0 {
Expand All @@ -55,23 +93,104 @@ func (a *Accounts) Import(ctx context.Context, input AccountImportInput, raw []b
continue
}
importer, ok := adapter.Credential.(providers.CredentialImporter)
if !ok || importer.Format() != input.Format {
if !ok || !credentialFormatMatches(descriptor, importer, input.Format) {
continue
}
prepared, err := importer.PrepareImport(payload)
chunks, err := splitCredentialImports(importer, payload)
if err != nil {
return accounts.Account{}, operationError("invalid_credential", err.Error())
return AccountImportResult{}, operationError("invalid_credential", err.Error())
}
account, err := a.ImportCredentialPayload(ctx, accounts.CreateAccount{
AutoCheckin: input.AutoCheckin, CheckinTime: input.CheckinTime,
Name: input.Name, Provider: descriptor.ID, Region: input.Region,
MaxInFlight: input.MaxInFlight, Priority: input.Priority, DropSystemPrompt: input.DropSystemPrompt,
WorkBuddyAutoCheckin: input.WorkBuddyAutoCheckin, WorkBuddyCheckinTime: input.WorkBuddyCheckinTime, ProxyURL: input.ProxyURL,
}, input.Format, prepared.Payload, prepared.Ready && input.Enabled)
if err != nil {
return account, operationError("account_import_failed", err.Error())
if len(chunks) == 0 {
return AccountImportResult{}, operationError("invalid_credential", "credential package is empty")
}
storeFormat := importer.Format()
result := AccountImportResult{Object: "import_result", Data: make([]accounts.Account, 0, len(chunks))}
for i, chunk := range chunks {
prepared, err := importer.PrepareImport(chunk)
if err != nil {
result.Failed++
result.Errors = append(result.Errors, AccountImportItemError{
Index: i, Message: fmt.Sprintf("accounts[%d]: %s", i, err.Error()),
})
continue
}
name := importAccountName(input.Name, prepared.Payload, i, len(chunks))
account, err := a.ImportCredentialPayload(ctx, accounts.CreateAccount{
AutoCheckin: input.AutoCheckin, CheckinTime: input.CheckinTime,
Name: name, Provider: descriptor.ID, Region: input.Region,
MaxInFlight: input.MaxInFlight, Priority: input.Priority, DropSystemPrompt: input.DropSystemPrompt,
WorkBuddyAutoCheckin: input.WorkBuddyAutoCheckin, WorkBuddyCheckinTime: input.WorkBuddyCheckinTime, ProxyURL: input.ProxyURL,
}, storeFormat, prepared.Payload, prepared.Ready && input.Enabled)
if err != nil {
result.Failed++
result.Errors = append(result.Errors, AccountImportItemError{
Index: i, Name: name, Message: err.Error(),
})
continue
}
result.Succeeded++
result.Data = append(result.Data, account)
}
if result.Succeeded == 0 {
msg := "account import failed"
if len(result.Errors) > 0 {
msg = result.Errors[0].Message
}
return result, operationError("account_import_failed", msg)
}
return result, nil
}
return AccountImportResult{}, operationError("unsupported_format", "unsupported account import format")
}

func credentialFormatMatches(descriptor providers.ProviderDescriptor, importer providers.CredentialImporter, format string) bool {
if importer.Format() == format {
return true
}
// Accept registry aliases (e.g. workbuddy-accounts → workbuddy-oauth-v1).
return descriptor.ID != "" && descriptor.SupportsCredentialFormat(format) &&
descriptor.SupportsCredentialFormat(importer.Format())
}

func splitCredentialImports(importer providers.CredentialImporter, payload []byte) ([][]byte, error) {
if expander, ok := importer.(providers.CredentialBatchExpander); ok {
return expander.SplitImport(payload)
}
return [][]byte{payload}, nil
}

func importAccountName(base string, payload []byte, index, total int) string {
base = strings.TrimSpace(base)
nickname := credentialDisplayName(payload)
if total <= 1 {
if base != "" {
return base
}
return account, nil
if nickname != "" {
return nickname
}
return ""
}
if base != "" && nickname != "" && !strings.EqualFold(base, nickname) {
return fmt.Sprintf("%s · %s", base, nickname)
}
if nickname != "" {
return nickname
}
if base != "" {
return fmt.Sprintf("%s #%d", base, index+1)
}
return fmt.Sprintf("account #%d", index+1)
}

func credentialDisplayName(payload []byte) string {
var meta struct {
Nickname string `json:"nickname"`
UID string `json:"uid"`
}
_ = json.Unmarshal(payload, &meta)
if name := strings.TrimSpace(meta.Nickname); name != "" {
return name
}
return accounts.Account{}, operationError("unsupported_format", "unsupported account import format")
return strings.TrimSpace(meta.UID)
}
Loading
Loading