openai_agent_identity_test.go 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. package service
  2. import (
  3. "context"
  4. "crypto/ed25519"
  5. "crypto/rand"
  6. "crypto/sha512"
  7. "crypto/x509"
  8. "encoding/base64"
  9. "encoding/json"
  10. "fmt"
  11. "net/http"
  12. "net/http/httptest"
  13. "strings"
  14. "sync"
  15. "testing"
  16. "time"
  17. "github.com/stretchr/testify/require"
  18. "golang.org/x/crypto/curve25519"
  19. "golang.org/x/crypto/nacl/box"
  20. )
  21. func newTestAgentIdentityKey(t *testing.T) (agentIdentityKey, string) {
  22. t.Helper()
  23. _, privateKey, err := ed25519.GenerateKey(rand.Reader)
  24. require.NoError(t, err)
  25. der, err := x509.MarshalPKCS8PrivateKey(privateKey)
  26. require.NoError(t, err)
  27. return agentIdentityKey{
  28. runtimeID: "runtime-test",
  29. privateKey: privateKey,
  30. taskID: "task-test",
  31. }, base64.StdEncoding.EncodeToString(der)
  32. }
  33. func TestBuildAgentAssertionMatchesCodexEnvelopeAndSignature(t *testing.T) {
  34. key, _ := newTestAgentIdentityKey(t)
  35. now := time.Date(2026, 7, 14, 8, 9, 10, 0, time.FixedZone("UTC+8", 8*60*60))
  36. assertion, err := buildAgentAssertion(key, now)
  37. require.NoError(t, err)
  38. require.True(t, strings.HasPrefix(assertion, "AgentAssertion "))
  39. encoded := strings.TrimPrefix(assertion, "AgentAssertion ")
  40. decoded, err := base64.RawURLEncoding.DecodeString(encoded)
  41. require.NoError(t, err)
  42. var envelope struct {
  43. AgentRuntimeID string `json:"agent_runtime_id"`
  44. TaskID string `json:"task_id"`
  45. Timestamp string `json:"timestamp"`
  46. Signature string `json:"signature"`
  47. }
  48. require.NoError(t, json.Unmarshal(decoded, &envelope))
  49. require.Equal(t, "runtime-test", envelope.AgentRuntimeID)
  50. require.Equal(t, "task-test", envelope.TaskID)
  51. require.Equal(t, "2026-07-14T00:09:10Z", envelope.Timestamp)
  52. signature, err := base64.StdEncoding.DecodeString(envelope.Signature)
  53. require.NoError(t, err)
  54. publicKey, ok := key.privateKey.Public().(ed25519.PublicKey)
  55. require.True(t, ok)
  56. require.True(t, ed25519.Verify(publicKey, []byte("runtime-test:task-test:2026-07-14T00:09:10Z"), signature))
  57. }
  58. func TestDecryptAgentTaskIDSupportsCodexSealedBoxResponse(t *testing.T) {
  59. key, _ := newTestAgentIdentityKey(t)
  60. digest := sha512.Sum512(key.privateKey.Seed())
  61. var curvePrivate [32]byte
  62. copy(curvePrivate[:], digest[:32])
  63. curvePrivate[0] &= 248
  64. curvePrivate[31] &= 127
  65. curvePrivate[31] |= 64
  66. curvePublicBytes, err := curve25519.X25519(curvePrivate[:], curve25519.Basepoint)
  67. require.NoError(t, err)
  68. var curvePublic [32]byte
  69. copy(curvePublic[:], curvePublicBytes)
  70. ciphertext, err := box.SealAnonymous(nil, []byte("task-sealed"), &curvePublic, rand.Reader)
  71. require.NoError(t, err)
  72. got, err := decryptAgentTaskID(key, base64.StdEncoding.EncodeToString(ciphertext))
  73. require.NoError(t, err)
  74. require.Equal(t, "task-sealed", got)
  75. }
  76. func TestRegisterAgentIdentityTaskAcceptsPlaintextAndEncryptedResponses(t *testing.T) {
  77. key, privateKey := newTestAgentIdentityKey(t)
  78. requestCount := 0
  79. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  80. require.Equal(t, http.MethodPost, r.Method)
  81. require.Equal(t, "/v1/agent/runtime-test/task/register", r.URL.Path)
  82. var request map[string]string
  83. require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
  84. require.NotEmpty(t, request["timestamp"])
  85. require.NotEmpty(t, request["signature"])
  86. requestCount++
  87. if requestCount == 2 {
  88. digest := sha512.Sum512(key.privateKey.Seed())
  89. var curvePrivate [32]byte
  90. copy(curvePrivate[:], digest[:32])
  91. curvePrivate[0] &= 248
  92. curvePrivate[31] &= 127
  93. curvePrivate[31] |= 64
  94. curvePublicBytes, curveErr := curve25519.X25519(curvePrivate[:], curve25519.Basepoint)
  95. require.NoError(t, curveErr)
  96. var curvePublic [32]byte
  97. copy(curvePublic[:], curvePublicBytes)
  98. ciphertext, sealErr := box.SealAnonymous(nil, []byte("task-encrypted"), &curvePublic, rand.Reader)
  99. require.NoError(t, sealErr)
  100. _, _ = fmt.Fprintf(w, `{"encrypted_task_id":%q}`, base64.StdEncoding.EncodeToString(ciphertext))
  101. return
  102. }
  103. _, _ = w.Write([]byte(`{"task_id":"task-plain"}`))
  104. }))
  105. defer server.Close()
  106. oldBase := openAIAgentIdentityAuthAPIBaseURL
  107. openAIAgentIdentityAuthAPIBaseURL = server.URL
  108. t.Cleanup(func() { openAIAgentIdentityAuthAPIBaseURL = oldBase })
  109. account := &Account{ID: 1, Type: AccountTypeOAuth, Platform: PlatformOpenAI, Credentials: map[string]any{
  110. "auth_mode": OpenAIAuthModeAgentIdentity,
  111. "agent_runtime_id": key.runtimeID,
  112. "agent_private_key": privateKey,
  113. }}
  114. taskID, err := registerAgentIdentityTask(context.Background(), account)
  115. require.NoError(t, err)
  116. require.Equal(t, "task-plain", taskID)
  117. taskID, err = registerAgentIdentityTask(context.Background(), account)
  118. require.NoError(t, err)
  119. require.Equal(t, "task-encrypted", taskID)
  120. }
  121. func TestEnsureAgentIdentityTaskPersistsAndRedactsCredentials(t *testing.T) {
  122. key, privateKey := newTestAgentIdentityKey(t)
  123. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  124. _, _ = w.Write([]byte(`{"task_id":"task-persisted"}`))
  125. }))
  126. defer server.Close()
  127. oldBase := openAIAgentIdentityAuthAPIBaseURL
  128. openAIAgentIdentityAuthAPIBaseURL = server.URL
  129. t.Cleanup(func() { openAIAgentIdentityAuthAPIBaseURL = oldBase })
  130. repo := &agentIdentityCredentialsRepo{}
  131. account := &Account{ID: 7, Type: AccountTypeOAuth, Platform: PlatformOpenAI, Credentials: map[string]any{
  132. "auth_mode": OpenAIAuthModeAgentIdentity,
  133. "agent_runtime_id": key.runtimeID,
  134. "agent_private_key": privateKey,
  135. "chatgpt_account_id": "account-test",
  136. }}
  137. service := &OpenAIGatewayService{accountRepo: repo}
  138. require.NoError(t, service.ensureAgentIdentityTask(context.Background(), account, ""))
  139. require.Equal(t, "task-persisted", account.GetCredential("task_id"))
  140. require.Equal(t, "task-persisted", repo.credentials["task_id"])
  141. require.True(t, IsSensitiveCredentialKey("agent_private_key"))
  142. redacted := make(map[string]any)
  143. for key, value := range account.Credentials {
  144. if !IsSensitiveCredentialKey(key) {
  145. redacted[key] = value
  146. }
  147. }
  148. require.NotContains(t, string(mustAgentIdentityJSON(t, redacted)), privateKey)
  149. }
  150. func TestEnsureAgentIdentityTaskSharesLockAcrossServicesForSameAccount(t *testing.T) {
  151. key, privateKey := newTestAgentIdentityKey(t)
  152. account := &Account{ID: 9001, Type: AccountTypeOAuth, Platform: PlatformOpenAI, Credentials: map[string]any{
  153. "auth_mode": OpenAIAuthModeAgentIdentity,
  154. "agent_runtime_id": key.runtimeID,
  155. "agent_private_key": privateKey,
  156. }}
  157. repo := &agentIdentityCredentialsRepo{account: account}
  158. registerCalls := 0
  159. var registerMu sync.Mutex
  160. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  161. registerMu.Lock()
  162. registerCalls++
  163. registerMu.Unlock()
  164. _, _ = w.Write([]byte(`{"task_id":"task-shared"}`))
  165. }))
  166. defer server.Close()
  167. oldBase := openAIAgentIdentityAuthAPIBaseURL
  168. openAIAgentIdentityAuthAPIBaseURL = server.URL
  169. t.Cleanup(func() { openAIAgentIdentityAuthAPIBaseURL = oldBase })
  170. start := make(chan struct{})
  171. errors := make(chan error, 2)
  172. requests := []*Account{cloneAgentIdentityTestAccount(account), cloneAgentIdentityTestAccount(account)}
  173. for _, request := range requests {
  174. go func() {
  175. <-start
  176. errors <- ensureAgentIdentityTaskForAccount(context.Background(), repo, nil, &sync.Mutex{}, request, "")
  177. }()
  178. }
  179. close(start)
  180. require.NoError(t, <-errors)
  181. require.NoError(t, <-errors)
  182. registerMu.Lock()
  183. defer registerMu.Unlock()
  184. require.Equal(t, 1, registerCalls)
  185. require.Equal(t, "task-shared", repo.account.GetCredential("task_id"))
  186. }
  187. func cloneAgentIdentityTestAccount(account *Account) *Account {
  188. copy := *account
  189. copy.Credentials = shallowCopyMap(account.Credentials)
  190. return &copy
  191. }
  192. type agentIdentityCredentialsRepo struct {
  193. AccountRepository
  194. credentials map[string]any
  195. account *Account
  196. mu sync.Mutex
  197. }
  198. func (r *agentIdentityCredentialsRepo) GetByID(_ context.Context, _ int64) (*Account, error) {
  199. return r.account, nil
  200. }
  201. func (r *agentIdentityCredentialsRepo) UpdateCredentials(_ context.Context, _ int64, credentials map[string]any) error {
  202. r.mu.Lock()
  203. defer r.mu.Unlock()
  204. r.credentials = credentials
  205. return nil
  206. }
  207. func mustAgentIdentityJSON(t *testing.T, value any) []byte {
  208. t.Helper()
  209. encoded, err := json.Marshal(value)
  210. require.NoError(t, err)
  211. return encoded
  212. }