diff --git a/backend/internal/handler/admin/account_codex_agent_identity_import_test.go b/backend/internal/handler/admin/account_codex_agent_identity_import_test.go index 85d1a21f4a..2407fe3f92 100644 --- a/backend/internal/handler/admin/account_codex_agent_identity_import_test.go +++ b/backend/internal/handler/admin/account_codex_agent_identity_import_test.go @@ -47,6 +47,99 @@ func TestNormalizeCodexImportEntryAcceptsAgentIdentityAuthJSON(t *testing.T) { require.NotEmpty(t, item.WarningTexts) } +func TestBuildCodexAgentIdentityKeysUseChatGPTAccountOnly(t *testing.T) { + keys := buildCodexAgentIdentityKeys("team-a") + require.Equal(t, []string{"account:team-a"}, keys) +} + +func TestCodexAgentIdentityIndexSeparatesTeamsForSameUser(t *testing.T) { + existing := service.Account{ + ID: 1, + Credentials: map[string]any{ + "auth_mode": service.OpenAIAuthModeAgentIdentity, + "chatgpt_account_id": "team-a", + "chatgpt_user_id": "same-user", + "agent_runtime_id": "runtime-a", + }, + } + index := buildCodexAccountIndex([]service.Account{existing}) + + teamBKeys := buildCodexAgentIdentityKeys("team-b") + matched, _ := index.Find(teamBKeys, "same-user") + require.Nil(t, matched) + + teamAKeys := buildCodexAgentIdentityKeys("team-a") + matched, matchedKey := index.Find(teamAKeys, "same-user") + require.NotNil(t, matched) + require.Equal(t, int64(1), matched.ID) + require.Equal(t, "account:team-a", matchedKey) +} + +func TestImportCodexSessionsKeepsAgentIdentityTeamsSeparate(t *testing.T) { + first := buildAgentIdentityImportValue(t, "runtime-a", "team-a", "same-user", "task-a") + second := buildAgentIdentityImportValue(t, "runtime-b", "team-b", "same-user", "task-b") + svc := newCodexImportMemoryAdminService(nil) + handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + + result, err := handler.importCodexSessions(context.Background(), CodexSessionImportRequest{ + SkipDefaultGroupBind: boolPtr(true), + }, []codexImportEntry{{Index: 1, Value: first}, {Index: 2, Value: second}}) + require.NoError(t, err) + require.Equal(t, 2, result.Created) + require.Zero(t, result.Updated) + require.Zero(t, result.Skipped) + require.Len(t, svc.createdAccounts, 2) +} + +func TestImportCodexSessionsMergesAgentIdentityRuntimesForSameTeam(t *testing.T) { + first := buildAgentIdentityImportValue(t, "runtime-a", "team-a", "same-user", "task-a") + second := buildAgentIdentityImportValue(t, "runtime-b", "team-a", "same-user", "task-b") + firstIdentity := first["agent_identity"].(map[string]any) + existing := service.Account{ + ID: 41, + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Credentials: map[string]any{ + "auth_mode": service.OpenAIAuthModeAgentIdentity, + "agent_runtime_id": firstIdentity["agent_runtime_id"], + "agent_private_key": firstIdentity["agent_private_key"], + "task_id": firstIdentity["task_id"], + "chatgpt_account_id": firstIdentity["account_id"], + "chatgpt_user_id": firstIdentity["chatgpt_user_id"], + }, + } + svc := newCodexImportMemoryAdminService([]service.Account{existing}) + handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + + result, err := handler.importCodexSessions(context.Background(), CodexSessionImportRequest{ + SkipDefaultGroupBind: boolPtr(true), + }, []codexImportEntry{{Index: 1, Value: second}}) + require.NoError(t, err) + require.Zero(t, result.Created) + require.Equal(t, 1, result.Updated) + require.Len(t, svc.updatedAccounts, 1) + require.Equal(t, "runtime-b", svc.updatedAccounts[0].input.Credentials["agent_runtime_id"]) + require.Equal(t, "task-b", svc.updatedAccounts[0].input.Credentials["task_id"]) +} + +func buildAgentIdentityImportValue(t *testing.T, runtimeID, accountID, userID, taskID string) map[string]any { + t.Helper() + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + der, err := x509.MarshalPKCS8PrivateKey(privateKey) + require.NoError(t, err) + return map[string]any{ + "auth_mode": "agentIdentity", + "agent_identity": map[string]any{ + "agent_runtime_id": runtimeID, + "agent_private_key": base64.StdEncoding.EncodeToString(der), + "task_id": taskID, + "account_id": accountID, + "chatgpt_user_id": userID, + }, + } +} + func TestImportCodexSessionsCreatesAgentIdentityWithoutOAuthExpiry(t *testing.T) { _, privateKey, err := ed25519.GenerateKey(rand.Reader) require.NoError(t, err) diff --git a/backend/internal/handler/admin/account_codex_import.go b/backend/internal/handler/admin/account_codex_import.go index aa471494f1..ba37ef3309 100644 --- a/backend/internal/handler/admin/account_codex_import.go +++ b/backend/internal/handler/admin/account_codex_import.go @@ -532,7 +532,7 @@ func normalizeCodexImportEntry(entry codexImportEntry) (*codexImportAccount, err if item.AgentTaskID == "" { item.WarningTexts = append(item.WarningTexts, "未包含 task_id,首次请求会使用现有 runtime 注册新 task") } - item.IdentityKeys = buildCodexAgentIdentityKeys(item.AccountID, item.UserID, item.Email, item.AgentRuntimeID) + item.IdentityKeys = buildCodexAgentIdentityKeys(item.AccountID) item.Name = buildCodexImportAccountName(item, entry.Index) return item, nil } @@ -894,12 +894,17 @@ func buildCodexImportIdentityKeys(accountID, userID, email, accessToken, refresh return buildCodexStoredIdentityKeys(accountID, userID, email, accessToken) } -func buildCodexAgentIdentityKeys(accountID, userID, email, runtimeID string) []string { - keys := buildCodexStoredIdentityKeys(accountID, userID, email, "") - if runtimeID = strings.TrimSpace(runtimeID); runtimeID != "" { - keys = append([]string{"agent:" + runtimeID}, keys...) +func buildCodexAgentIdentityKeys(accountID string) []string { + // Agent Identity credentials belonging to the same ChatGPT account are + // intentionally merged, while the same user may own multiple accounts. + // Do not use user/email/runtime as fallback keys here: user_id is shared + // across Team workspaces and runtime_id changes when a new runtime is + // registered for the same account. + accountID = strings.TrimSpace(accountID) + if accountID == "" { + return nil } - return keys + return []string{"account:" + accountID} } // buildCodexStoredIdentityKeys 生成存量账号索引键,保留 user/account 维度,