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
5 changes: 3 additions & 2 deletions services/core/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Domain owners, each with its PostgreSQL adapter under `internal/persistence/post

- `agents` (`agentpg`): saved Agents, their configuration merge and bounds, and the encrypted model-provider bundle bound to each Agent.
- `files` (`filepg`): source Files.
- `vaults` (`vaultpg`): Vaults and Credentials, the encryption of Credential secrets, OAuth access-token refresh, and the MCP credential selection that Session creation freezes and the Dispatcher's `Credentials` resolves into a bearer token.

## Request handling

Expand Down Expand Up @@ -113,7 +114,7 @@ Provider input validation uses the adapter rules in `internal/harnessconfig`: on

## Vaults and credentials

[Vaults and credentials](../../contracts/agents-api/vaults.md) describes the resources, selection rules, refresh and deletion. The store implements them under these rules:
[Vaults and credentials](../../contracts/agents-api/vaults.md) describes the resources, selection rules, refresh and deletion. `vaults` implements them, with `vaultpg` as its storage, under these rules:

- Credentials are children of tenant-owned Vaults. Creation admits the owner in the same SQL statement as the insert; retrieval joins the owning Vault; listing enforces Project and Vault ownership on the parent, cursor and row query. Metadata queries never select ciphertext and need no encryption key.
- Secret values are encrypted before they reach SQL, with Core's separately configured random 32-byte key and the standard library's random-nonce AES-GCM. The versioned authenticated binding covers tenant, Vault, Credential, authentication purpose and exact destination. Never reuse daemon transport encryption for this storage. A missing key disables credential writes with `credentialcrypto.ErrUnavailable`; a malformed configured key fails startup.
Expand All @@ -124,7 +125,7 @@ Provider input validation uses the adapter rules in `internal/harnessconfig`: on

- `credentialcrypto` ciphertext is a format version byte followed by the standard AEAD nonce, ciphertext and tag. The authenticated data holds a fixed domain and version plus the binding (tenant, Vault, Credential, auth type, exact destination). Keep the domain string unchanged: existing rows must still decrypt.
- Random-nonce GCM allows at most 2^32 encryptions per key. `secrets/credential.key` also seals model providers, the E2B key, Skills, initial files and environment setup, so every sealed write counts toward that bound; there is no rotation or re-encryption path.
- OAuth dispatch refresh holds the Credential row lock and the external exchange under one 20-second context (`store.oauthRefreshTimeout`). The refresh HTTP client has a 10-second overall timeout and 5-second TLS handshake and response-header timeouts, uses no proxy and treats any redirect as failure.
- OAuth dispatch refresh holds the Credential row lock and the external exchange under one 20-second context (`vaults.oauthRefreshTimeout`). The refresh HTTP client has a 10-second overall timeout and 5-second TLS handshake and response-header timeouts, uses no proxy and treats any redirect as failure.

## MCP

Expand Down
3 changes: 2 additions & 1 deletion services/core/cmd/server/http_routes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ func daemonComposition(t testing.TB) http.Handler {
}
apiHandler, err := api.NewHandler(api.Dependencies{
Engine: "codex", CoreKeys: admin, InstallationBindings: struct{ api.InstallationBindings }{},
Projects: trapProjects{keys: keys}, Vaults: struct{ api.Vaults }{}, ModelProviders: struct{ api.ModelProviders }{},
Projects: trapProjects{keys: keys}, ModelProviders: struct{ api.ModelProviders }{},
Vaults: struct{ api.Vaults }{}, VaultsReader: struct{ api.VaultsReader }{},
Files: struct{ api.Files }{}, FilesReader: struct{ api.FilesReader }{}, Skills: struct{ api.Skills }{}, EnvironmentTemplates: struct{ api.EnvironmentTemplates }{},
Agents: struct{ api.Agents }{}, AgentsReader: struct{ api.AgentsReader }{}, Sessions: struct{ api.Sessions }{}, SessionEvents: struct{ api.SessionEvents }{},
SessionHistory: struct{ api.SessionHistory }{}, Subagents: struct{ api.Subagents }{}, Artifacts: struct{ api.Artifacts }{},
Expand Down
14 changes: 11 additions & 3 deletions services/core/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import (
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/filepg"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/vaultpg"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtime"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeenrollment"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway"
Expand All @@ -50,6 +51,7 @@ import (
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs"
observationstoreresolver "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs/storeresolver"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/vaults"
"github.com/jackc/pgx/v5/pgxpool"
)

Expand Down Expand Up @@ -109,7 +111,7 @@ func run() error {
if err != nil {
return err
}
executionStore := store.NewWithCredentialCipherAndOAuthRefresh(pool, credentialKey, oauthClient)
executionStore := store.NewWithCredentialCipher(pool, credentialKey)
executionStore.SetPublicURL(public)
units := pgunit.NewPool(pool)
auditStore := auditpg.New(units)
Expand All @@ -118,6 +120,11 @@ func run() error {
if err != nil {
return err
}
vaultStore := vaultpg.New(units)
vaultService, err := vaults.NewService(vaultStore, credentialKey, oauthClient)
if err != nil {
return err
}
installation, err := installationFacts(public)
if err != nil {
return err
Expand Down Expand Up @@ -237,7 +244,7 @@ func run() error {
}
}
if registry != nil {
dispatcher := &execution.Dispatcher{Store: executionStore, Registry: registry,
dispatcher := &execution.Dispatcher{Store: executionStore, Registry: registry, Credentials: vaultService,
ManagedRuntimes: managed, MaxConcurrentExecutions: concurrency}
lease, err := pgunit.AcquireLease(ctx, pool)
if err != nil {
Expand Down Expand Up @@ -309,7 +316,8 @@ func run() error {
deps := api.Dependencies{
Engine: engine, Harnesses: kinds, CoreKeys: keyAdmin,
Installation: installation, InstallationBindings: executionStore,
Projects: executionStore, Vaults: executionStore, ModelProviders: executionStore,
Projects: executionStore, ModelProviders: executionStore,
Vaults: vaultService, VaultsReader: vaultStore,
Skills: executionStore, EnvironmentTemplates: executionStore,
Files: fileService, FilesReader: fileStore,
Agents: agentService, AgentsReader: agentStore,
Expand Down
10 changes: 5 additions & 5 deletions services/core/internal/api/claude_mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,25 @@ import (
"strings"
"testing"

"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/vaults"
"github.com/google/uuid"
)

type claudeCredentialStore struct {
binding store.MCPCredentialBinding
binding vaults.MCPCredentialBinding
calls int
}

func (s *claudeCredentialStore) ResolveMCPCredentials(_ context.Context, _ string, _ []string, _ []store.MCPCredentialRequest) ([]store.MCPCredentialBinding, error) {
func (s *claudeCredentialStore) ResolveMCPCredentials(context.Context, vaults.ResolveMCPCredentials) ([]vaults.MCPCredentialBinding, error) {
s.calls++
return []store.MCPCredentialBinding{s.binding}, nil
return []vaults.MCPCredentialBinding{s.binding}, nil
}

func TestClaudeMCPAdmitsResolvedCredentials(t *testing.T) {
for _, selection := range []string{"implicit", "explicit", "unmatched"} {
t.Run(selection, func(t *testing.T) {
vault, credential := uuid.NewString(), uuid.NewString()
s := &claudeCredentialStore{binding: store.MCPCredentialBinding{ServerLabel: "records", ServerURL: "https://mcp.example.test/tools", VaultID: vault, CredentialID: credential, AuthType: "static_bearer"}}
s := &claudeCredentialStore{binding: vaults.MCPCredentialBinding{ServerLabel: "records", ServerURL: "https://mcp.example.test/tools", VaultID: vault, CredentialID: credential, AuthType: "static_bearer"}}
tool := publicMCP
if selection == "explicit" {
tool = strings.TrimSuffix(tool, "}") + `,"credential_id":"` + credential + `"}`
Expand Down
10 changes: 5 additions & 5 deletions services/core/internal/api/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ import (

v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/metadata"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/vaults"
"github.com/google/uuid"
)

type configuration struct {
Agent v1.Agent `json:"agent"`
Environment v1.Environment `json:"environment"`
VaultIDs []string `json:"vault_ids,omitempty"`
MCPCredentials []store.MCPCredentialBinding `json:"mcp_credentials,omitempty"`
Agent v1.Agent `json:"agent"`
Environment v1.Environment `json:"environment"`
VaultIDs []string `json:"vault_ids,omitempty"`
MCPCredentials []vaults.MCPCredentialBinding `json:"mcp_credentials,omitempty"`
}

func resolve(input sessionRequest, tenant, key string, saved *v1.SavedAgent) (json.RawMessage, error) {
Expand Down
23 changes: 12 additions & 11 deletions services/core/internal/api/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import (
"net/http"

v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/vaults"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
Expand Down Expand Up @@ -41,28 +41,29 @@ func (h *Handler) createCredential(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
var credential store.Credential
var credential vaults.Credential
switch credentialAuthType(request.Auth) {
case "static_bearer":
var auth v1.CredentialAuthInput
if decodeInputObject(request.Auth, &auth, "type", "mcp_server_url", "token") != nil || auth.Token == nil || *auth.Token == "" || !credentialHTTPSURL(auth.MCPServerURL) {
writeError(w, http.StatusBadRequest, "invalid_request", "static_bearer requires a nonempty string token and an absolute HTTPS mcp_server_url without userinfo or a fragment.")
return
}
credential, err = h.Vaults.CreateStaticCredential(r.Context(), tenantID(r), vaultID, store.CreateStaticCredentialInput{Name: name, MCPServerURL: *auth.MCPServerURL, Token: *auth.Token})
credential, err = h.Vaults.CreateStaticCredential(r.Context(), vaults.CreateStaticCredential{TenantID: tenantID(r), VaultID: vaultID, Name: name, MCPServerURL: *auth.MCPServerURL, Token: *auth.Token})
case "mcp_oauth":
input, parseErr := oauthCredentialCreate(request.Auth, name)
command, parseErr := oauthCredentialCreate(request.Auth, name)
if parseErr != nil {
writeStoreError(w, r, parseErr)
writeVaultsError(w, r, parseErr)
return
}
credential, err = h.Vaults.CreateOAuthCredential(r.Context(), tenantID(r), vaultID, input)
command.TenantID, command.VaultID = tenantID(r), vaultID
credential, err = h.Vaults.CreateOAuthCredential(r.Context(), command)
default:
writeError(w, http.StatusBadRequest, "invalid_request", "auth requires type static_bearer or mcp_oauth.")
return
}
if err != nil {
writeStoreError(w, r, err)
writeVaultsError(w, r, err)
return
}
writeJSON(w, http.StatusCreated, credentialResponse(credential))
Expand All @@ -88,9 +89,9 @@ func (h *Handler) getCredential(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
credential, err := h.Vaults.GetCredential(r.Context(), tenantID(r), vaultID, id)
credential, err := h.VaultsReader.GetCredential(r.Context(), tenantID(r), vaultID, id)
if err != nil {
writeStoreError(w, r, err)
writeVaultsError(w, r, err)
return
}
writeJSON(w, http.StatusOK, credentialResponse(credential))
Expand All @@ -101,13 +102,13 @@ func (h *Handler) getCredential(w http.ResponseWriter, r *http.Request) {
func credentialResourceID(w http.ResponseWriter, r *http.Request, param string) (string, bool) {
id, err := uuid.Parse(chi.URLParam(r, param))
if err != nil || id == uuid.Nil {
writeStoreError(w, r, store.ErrNotFound)
writeVaultsError(w, r, vaults.ErrNotFound)
return "", false
}
return id.String(), true
}

func credentialResponse(c store.Credential) v1.Credential {
func credentialResponse(c vaults.Credential) v1.Credential {
auth := v1.CredentialAuth{Type: c.AuthType, MCPServerURL: c.MCPServerURL}
if c.OAuth != nil {
auth.ExpiresAt = c.OAuth.ExpiresAt
Expand Down
5 changes: 3 additions & 2 deletions services/core/internal/api/credentials_delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"net/http"

v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/vaults"
)

// @Summary Delete a Vault Credential
Expand Down Expand Up @@ -35,9 +36,9 @@ func (h *Handler) deleteCredential(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
deleted, err := h.Vaults.DeleteCredential(r.Context(), tenantID(r), vaultID, id)
deleted, err := h.Vaults.DeleteCredential(r.Context(), vaults.DeleteCredential{TenantID: tenantID(r), VaultID: vaultID, CredentialID: id})
if err != nil {
writeStoreError(w, r, err)
writeVaultsError(w, r, err)
return
}
writeJSON(w, http.StatusOK, v1.CredentialDeleted{ID: deleted, Deleted: true, Object: "vault.credential.deleted"})
Expand Down
8 changes: 4 additions & 4 deletions services/core/internal/api/credentials_delete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ import (
"strings"
"testing"

"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/vaults"
"github.com/google/uuid"
)

func (f *credentialFixture) DeleteCredential(_ context.Context, tenant, vault, id string) (string, error) {
f.tenant, f.vault, f.id, f.calls = tenant, vault, id, f.calls+1
func (f *credentialFixture) DeleteCredential(_ context.Context, command vaults.DeleteCredential) (string, error) {
f.tenant, f.vault, f.id, f.calls = command.TenantID, command.VaultID, command.CredentialID, f.calls+1
return f.credential.ID, f.err
}

Expand Down Expand Up @@ -74,7 +74,7 @@ func TestCredentialDeletionRejectsBeforeMutation(t *testing.T) {
for _, tc := range []struct {
err error
status int
}{{store.ErrNotFound, 404}, {errors.New("credential-canary"), 500}} {
}{{vaults.ErrNotFound, 404}, {errors.New("credential-canary"), 500}} {
h, f, _ := credentialHandler(t)
f.err = tc.err
w := credentialRequest(h, "DELETE", "/v1/vaults/"+f.credential.VaultID+"/credentials/"+f.credential.ID, "")
Expand Down
6 changes: 3 additions & 3 deletions services/core/internal/api/credentials_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@ import (
// @Router /vaults/{vault_id}/credentials [get]
func (h *Handler) listCredentials(w http.ResponseWriter, r *http.Request) {
vaultID := chi.URLParam(r, "vault_id")
options, statuses, ok := readVaultPage(w, r)
query, ok := readVaultPage(w, r)
if !ok {
return
}
page, err := h.Vaults.ListCredentials(r.Context(), tenantID(r), vaultID, options.after, options.limit, options.ascending, statuses)
page, err := h.VaultsReader.ListCredentials(r.Context(), tenantID(r), vaultID, query)
if err != nil {
writeStoreError(w, r, err)
writeVaultsError(w, r, err)
return
}
response := v1.CredentialList{Object: "list", Data: make([]v1.Credential, 0, len(page.Credentials)), HasMore: page.NextCursor != ""}
Expand Down
17 changes: 8 additions & 9 deletions services/core/internal/api/credentials_list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,11 @@ import (
"testing"

v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/vaults"
)

func (f *credentialFixture) ListCredentials(_ context.Context, tenant, vault, after string, limit int, ascending bool, statuses []string) (store.CredentialPage, error) {
f.tenant, f.vault, f.calls = tenant, vault, f.calls+1
f.options, f.statuses = pageOptions{after: after, limit: limit, ascending: ascending}, statuses
func (f *credentialFixture) ListCredentials(_ context.Context, tenant, vault string, query vaults.PageQuery) (vaults.CredentialPage, error) {
f.tenant, f.vault, f.query, f.calls = tenant, vault, query, f.calls+1
return f.page, f.err
}

Expand All @@ -29,13 +28,13 @@ func TestCredentialListScopeProjectionAndParameters(t *testing.T) {
{"?limit=-3&tenant_id=foreign&unknown=1", 1, nil},
} {
h, f, tenant := credentialHandler(t)
f.page = store.CredentialPage{Credentials: []store.Credential{f.credential}, NextCursor: f.credential.ID}
f.page = vaults.CredentialPage{Credentials: []vaults.Credential{f.credential}, NextCursor: f.credential.ID}
w := credentialRequest(h, "GET", "/v1/vaults/"+f.credential.VaultID+"/credentials"+tc.query, "")
var body v1.CredentialList
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &body) != nil {
t.Fatal(w.Code, w.Body.String())
}
if f.calls != 1 || f.tenant != tenant || f.vault != f.credential.VaultID || f.options.limit != tc.limit || !reflect.DeepEqual(f.statuses, tc.statuses) {
if f.calls != 1 || f.tenant != tenant || f.vault != f.credential.VaultID || f.query.Limit != tc.limit || !reflect.DeepEqual(f.query.Statuses, tc.statuses) {
t.Fatal("list scope or parameters changed")
}
if !reflect.DeepEqual(body.Data, []v1.Credential{credentialResponse(f.credential)}) || !body.HasMore || body.FirstID == nil || *body.FirstID != f.credential.ID || body.LastID == nil || *body.LastID != f.credential.ID {
Expand All @@ -46,10 +45,10 @@ func TestCredentialListScopeProjectionAndParameters(t *testing.T) {
path := "/v1/vaults/" + f.credential.VaultID + "/credentials"
w := credentialRequest(h, "GET", path+"?order=asc&after="+f.credential.ID, "")
var empty map[string]any
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &empty) != nil || !reflect.DeepEqual(empty, map[string]any{"object": "list", "data": []any{}, "has_more": false, "first_id": nil, "last_id": nil}) || !f.options.ascending || f.options.after != f.credential.ID {
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &empty) != nil || !reflect.DeepEqual(empty, map[string]any{"object": "list", "data": []any{}, "has_more": false, "first_id": nil, "last_id": nil}) || !f.query.Ascending || f.query.After != f.credential.ID {
t.Fatal("empty page or cursor parsing changed")
}
f.err = store.ErrNotFound
f.err = vaults.ErrNotFound
if w = credentialRequest(h, "GET", path, ""); w.Code != 404 {
t.Fatal("missing parent must not be an empty collection")
}
Expand All @@ -66,7 +65,7 @@ func TestCredentialListRejectsInvalidInputBeforeStorage(t *testing.T) {
// A malformed parent reaches storage unchanged after query validation, and
// storage reports it as a missing Vault.
h, f, _ := credentialHandler(t)
f.err = store.ErrNotFound
f.err = vaults.ErrNotFound
if w := credentialRequest(h, "GET", "/v1/vaults/invalid/credentials", ""); w.Code != 404 || f.vault != "invalid" {
t.Fatal("invalid parent was not resolved as a missing Vault", w.Code, f.vault)
}
Expand Down
Loading