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
2 changes: 1 addition & 1 deletion cmd/mecak8s/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,7 @@ func parseFlags(argv []string) (config, error) {
// OPENAI/OPENROUTER/ANTHROPIC_API_KEY — the SAME helper mecated/mecatequi
// use, so mecak8s shares the three-mains wiring.
cfg.providerFlags = cliconfig.RegisterProviderFlags(fs, cliconfig.ProviderFlagHelp{
AuthFile: "provider and MCP credentials YAML path (API-key providers and global MCP bearer tokens; Codex OAuth is unsupported)",
AuthFile: "provider, search, and MCP credentials YAML path (API-key providers and global MCP bearer tokens; Codex OAuth is unsupported)",
})
// ToolHive LLM gateway (issue #262): a k8s pod naturally has no ToolHive
// config file, so this is inert unless an operator mounts one or passes
Expand Down
2 changes: 1 addition & 1 deletion cmd/mecated/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ func TestApplyPostureCLIYoloWarning(t *testing.T) {

func TestSearchCommandRootProjection(t *testing.T) {
path := filepath.Join(t.TempDir(), "auth.yaml")
if err := os.WriteFile(path, []byte("providers:\n exa:\n api_key: daemon-exa-key\n brave:\n api_key: daemon-brave-key\n websearch:\n api_key: daemon-websearch-key\n"), 0o600); err != nil {
if err := os.WriteFile(path, []byte("search:\n exa:\n api_key: daemon-exa-key\n brave:\n api_key: daemon-brave-key\n websearch:\n api_key: daemon-websearch-key\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("EXA_API_KEY", "")
Expand Down
2 changes: 1 addition & 1 deletion cmd/mecated/mcp_authfile_lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func TestMCPDirectLifecycleStaticSourceSelection(t *testing.T) {
path = filepath.Join(t.TempDir(), "selected.yaml")
prefix = "credential_store:\n api_key:\n file: " + path + "\n"
}
if err := os.WriteFile(path, []byte("mcp:\n lifecycle_test:\n token: selected-direct-token\n"), 0o600); err != nil {
if err := os.WriteFile(path, []byte("mcp:\n lifecycle_test:\n api_key: selected-direct-token\n"), 0o600); err != nil {
t.Fatal(err)
}
settings := filepath.Join(t.TempDir(), "settings.yaml")
Expand Down
2 changes: 1 addition & 1 deletion cmd/mecatui/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1378,7 +1378,7 @@ func TestOpenAICodexCommandRootSurfaces(t *testing.T) {
t.Setenv(envName, "")
}
help := helpRenderOut(t, modeLocal, []string{"--help"})
if !hasFlagHeader(help, "api-key-file") || !strings.Contains(help, "provider and MCP credentials YAML") {
if !hasFlagHeader(help, "api-key-file") || !strings.Contains(help, "provider, search, and MCP credentials YAML") {
t.Fatalf("embedded help does not surface provider file auth:\n%s", help)
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/mecatui/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ func TestEmbeddedCredentialStartup(t *testing.T) {
writeIsolatedExecutionSettings(t, app.PlacementHostLocal)
const key = "embedded-search-key"
authPath := filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "mecatl", "auth.yaml")
if err := os.WriteFile(authPath, []byte("providers:\n "+tt.provider+":\n api_key: "+key+"\n"), 0o600); err != nil {
if err := os.WriteFile(authPath, []byte("search:\n "+tt.provider+":\n api_key: "+key+"\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("EXA_API_KEY", "")
Expand Down
2 changes: 1 addition & 1 deletion cmd/mecatui/mcp_authfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func TestConnectSkipsEmbeddedAuthFileComposition(t *testing.T) {
t.Fatal(err)
}
path := filepath.Join(authDir, "auth.yaml")
if err := os.WriteFile(path, []byte("mcp:\n local:\n token: "+localToken+"\n"), 0o600); err != nil {
if err := os.WriteFile(path, []byte("mcp:\n local:\n api_key: "+localToken+"\n"), 0o600); err != nil {
t.Fatal(err)
}
originalEnv := xdgconfig.OSEnv
Expand Down
99 changes: 58 additions & 41 deletions internal/adapter/authfile/authfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,22 +62,24 @@ type providerEntry struct {
// equivalent (a project has no business supplying credentials).
type File struct {
providers map[string]providerEntry
search map[string]string
mcp map[string]string
path string
baseWarning string
}

var mcpCredentialID = regexp.MustCompile(`^[a-z0-9_]+$`)
var staticCredentialID = regexp.MustCompile(`^[a-z0-9_]+$`)

type rawFile struct {
Providers map[string]rawProviderEntry `yaml:"providers"`
MCP map[string]rawMCPEntry `yaml:"mcp"`
Search map[string]rawAPIKeyEntry `yaml:"search"`
MCP map[string]rawAPIKeyEntry `yaml:"mcp"`
}

// rawMCPEntry is deliberately kept private: auth.yaml token values are exposed
// only through File.MCPToken.
type rawMCPEntry struct {
Token strictString `yaml:"token"`
// rawAPIKeyEntry is deliberately kept private: auth.yaml API-key values are
// exposed only through namespace-specific File accessors.
type rawAPIKeyEntry struct {
APIKey strictString `yaml:"api_key"`
}

type rawProviderEntry struct {
Expand Down Expand Up @@ -141,9 +143,18 @@ func (f *File) OAuth(name string) OAuthEntry {
return entry.oauth
}

// MCPToken returns the opaque token for the exact lowercase MCP credential
// ID, or "" if f is nil or no entry exists. Valid tokens are returned unchanged.
func (f *File) MCPToken(id string) string {
// SearchAPIKey returns the api_key for the exact lowercase search credential ID,
// or "" if f is nil or has no entry. Valid API keys are returned unchanged.
func (f *File) SearchAPIKey(name string) string {
if f == nil {
return ""
}
return f.search[name]
}

// MCPAPIKey returns the api_key for the exact lowercase MCP credential ID, or
// "" if f is nil or no entry exists. Valid API keys are returned unchanged.
func (f *File) MCPAPIKey(id string) string {
if f == nil {
return ""
}
Expand Down Expand Up @@ -237,11 +248,11 @@ func Load(path string, explicit bool, env xdgconfig.ResolveEnv, knownProviders [
if len(file.Docs) != 1 || file.Docs[0] == nil || file.Docs[0].Body == nil || len(ast.Filter(ast.NullType, file.Docs[0].Body)) > 0 {
return nil, schemaWarning(path, nil)
}
if err := validateMCPAuthSchema(file.Docs[0].Body); err != nil {
if err := validateStaticAPIKeyAuthSchema(file.Docs[0].Body); err != nil {
return nil, schemaWarning(path, nil)
}
var raw rawFile
if err := yaml.NodeToValue(file.Docs[0].Body, &raw, yaml.DisallowUnknownField()); err != nil || (raw.Providers == nil && raw.MCP == nil) {
if err := yaml.NodeToValue(file.Docs[0].Body, &raw, yaml.DisallowUnknownField()); err != nil || (raw.Providers == nil && raw.Search == nil && raw.MCP == nil) {
return nil, schemaWarning(path, nil)
}
// Count, never the names: an unknown provider name is an arbitrary YAML
Expand All @@ -251,7 +262,8 @@ func Load(path string, explicit bool, env xdgconfig.ResolveEnv, knownProviders [
// the whole warning surface, not just that one branch.
f := File{
providers: make(map[string]providerEntry, len(raw.Providers)),
mcp: make(map[string]string, len(raw.MCP)),
search: apiKeyValues(raw.Search),
mcp: apiKeyValues(raw.MCP),
path: path,
baseWarning: permWarning,
}
Expand All @@ -267,12 +279,17 @@ func Load(path string, explicit bool, env xdgconfig.ResolveEnv, knownProviders [
}
f.providers[name] = entry
}
for id, rawEntry := range raw.MCP {
f.mcp[id] = string(rawEntry.Token)
}
return &f, f.ValidateKnown(knownProviders)
}

func apiKeyValues(entries map[string]rawAPIKeyEntry) map[string]string {
values := make(map[string]string, len(entries))
for id, entry := range entries {
values[id] = string(entry.APIKey)
}
return values
}

// ValidateKnown applies the command root's final provider allowlist to an
// already parsed immutable snapshot. It is intentionally separate from Load so
// roots can first discover custom provider IDs, then validate auth.yaml without
Expand Down Expand Up @@ -309,25 +326,25 @@ func (f *File) ValidateKnown(knownProviders []string) string {
return joinWarnings(append([]string{f.baseWarning}, contentWarnings...)...)
}

// validateMCPAuthSchema checks only the newly introduced namespace before the
// typed decoder validates the established provider schema. Keeping those paths
// separate preserves Load's anchor and alias compatibility for providers.
func validateMCPAuthSchema(node ast.Node) error {
// validateStaticAPIKeyAuthSchema checks the static API-key namespaces before
// the typed decoder validates the established provider schema. Keeping those
// paths separate preserves Load's anchor and alias compatibility for providers.
func validateStaticAPIKeyAuthSchema(node ast.Node) error {
root, ok := authMapping(node)
if !ok {
return nil
}
seenMCP := false
seen := map[string]bool{}
for _, entry := range root.Values {
key, ok := authString(entry.Key)
if !ok || key != "mcp" {
if !ok || (key != "search" && key != "mcp") {
continue
}
if seenMCP {
return errors.New("auth root has duplicate mcp schema")
if seen[key] {
return errors.New("auth root has duplicate static API-key schema")
}
seenMCP = true
if err := validateMCPMapping(entry.Value); err != nil {
seen[key] = true
if err := validateStaticAPIKeyMapping(entry.Value, key); err != nil {
return err
}
}
Expand Down Expand Up @@ -358,7 +375,7 @@ func validateAuthSchema(node ast.Node) error {
seen := map[string]bool{}
for _, entry := range root.Values {
key, ok := authString(entry.Key)
if !ok || seen[key] || (key != "providers" && key != "mcp") {
if !ok || seen[key] || (key != "providers" && key != "search" && key != "mcp") {
return errors.New("auth root has duplicate or unknown schema")
}
seen[key] = true
Expand All @@ -367,45 +384,45 @@ func validateAuthSchema(node ast.Node) error {
if _, ok := entry.Value.(*ast.MappingNode); !ok {
return errors.New("auth providers must be a mapping")
}
case "mcp":
if err := validateMCPMapping(entry.Value); err != nil {
case "search", "mcp":
if err := validateStaticAPIKeyMapping(entry.Value, key); err != nil {
return err
}
}
}
return nil
}

func validateMCPMapping(node ast.Node) error {
mcp, ok := authMapping(node)
func validateStaticAPIKeyMapping(node ast.Node, namespace string) error {
entries, ok := authMapping(node)
if !ok {
return errors.New("auth mcp must be a mapping")
return fmt.Errorf("auth %s must be a mapping", namespace)
}
seen := map[string]bool{}
for _, entry := range mcp.Values {
for _, entry := range entries.Values {
id, ok := authString(entry.Key)
if !ok || !mcpCredentialID.MatchString(id) || seen[id] {
return errors.New("auth mcp contains an invalid or duplicate id")
if !ok || !staticCredentialID.MatchString(id) || seen[id] {
return fmt.Errorf("auth %s contains an invalid or duplicate id", namespace)
}
seen[id] = true
fields, ok := entry.Value.(*ast.MappingNode)
if !ok {
return errors.New("auth mcp entry must be a mapping")
return fmt.Errorf("auth %s entry must be a mapping", namespace)
}
found := false
for _, field := range fields.Values {
key, ok := authString(field.Key)
if !ok || key != "token" || found {
return errors.New("auth mcp entry has duplicate or unknown schema")
if !ok || key != "api_key" || found {
return fmt.Errorf("auth %s entry has duplicate or unknown schema", namespace)
}
value, ok := authString(field.Value)
if !ok || strings.TrimSpace(value) == "" {
return errors.New("auth mcp token must be a non-blank string")
return fmt.Errorf("auth %s api_key must be a non-blank string", namespace)
}
found = true
}
if !found {
return errors.New("auth mcp entry requires token")
return fmt.Errorf("auth %s entry requires api_key", namespace)
}
}
return nil
Expand All @@ -415,14 +432,14 @@ func schemaWarning(path string, parseErr error) string {
location := yamldiag.Classify("parse auth file", parseErr)
if location.HasLocation {
return fmt.Sprintf(
"auth file %s: does not match the expected schema at line %d, column %d (providers.<name>.api_key, providers.openai-codex.oauth, or mcp.<id>.token) — check indentation and field names",
"auth file %s: does not match the expected schema at line %d, column %d (providers.<name>.api_key, providers.openai-codex.oauth, search.<id>.api_key, or mcp.<id>.api_key) — check indentation and field names",
path,
location.Line,
location.Column,
)
}
return fmt.Sprintf(
"auth file %s: does not match the expected schema (providers.<name>.api_key, providers.openai-codex.oauth, or mcp.<id>.token) — check indentation and field names",
"auth file %s: does not match the expected schema (providers.<name>.api_key, providers.openai-codex.oauth, search.<id>.api_key, or mcp.<id>.api_key) — check indentation and field names",
path,
)
}
Expand Down
60 changes: 38 additions & 22 deletions internal/adapter/authfile/authfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,12 +216,15 @@ func TestLoadRejectsMalformedProviderStructure(t *testing.T) {
}
}

func TestMCPTokenSchemaAndAccessor(t *testing.T) {
const token = "mcp-token-with-surrounding-space "
quotedToken := "\"" + token + "\""
func TestStaticAPIKeySchemaAndAccessors(t *testing.T) {
const apiKey = "api-key-with-surrounding-space "
quotedAPIKey := "\"" + apiKey + "\""
for name, contents := range map[string]string{
"MCP only": "mcp:\n context7:\n token: " + quotedToken + "\n",
"mixed": "providers:\n anthropic:\n api_key: provider-key\nmcp:\n context7:\n token: " + quotedToken + "\n",
"search only": "search:\n exa:\n api_key: " + quotedAPIKey + "\n",
"websearch": "search:\n websearch:\n api_key: " + quotedAPIKey + "\n",
"empty static mappings": "search: {}\nmcp: {}\n",
"MCP only": "mcp:\n context7:\n api_key: " + quotedAPIKey + "\n",
"mixed": "providers:\n anthropic:\n api_key: provider-key\nsearch:\n brave:\n api_key: " + quotedAPIKey + "\nmcp:\n context7:\n api_key: " + quotedAPIKey + "\n",
} {
t.Run(name, func(t *testing.T) {
dir := t.TempDir()
Expand All @@ -230,30 +233,43 @@ func TestMCPTokenSchemaAndAccessor(t *testing.T) {
if warning != "" || file == nil {
t.Fatalf("Load = (%v, %q), want valid file", file, warning)
}
if got := file.MCPToken("context7"); got != token {
t.Fatalf("MCPToken(context7) = %q, want exact token", got)
if got := file.SearchAPIKey("exa"); name == "search only" && got != apiKey {
t.Fatalf("SearchAPIKey(exa) = %q, want exact API key", got)
}
if got := file.MCPToken("Context7"); got != "" {
t.Fatalf("MCPToken case-insensitive lookup = %q, want empty", got)
if got := file.SearchAPIKey("brave"); name == "mixed" && got != apiKey {
t.Fatalf("SearchAPIKey(brave) = %q, want exact API key", got)
}
if got := file.SearchAPIKey("websearch"); name == "websearch" && got != apiKey {
t.Fatalf("SearchAPIKey(websearch) = %q, want exact API key", got)
}
if got := file.MCPAPIKey("context7"); name != "search only" && name != "websearch" && name != "empty static mappings" && got != apiKey {
t.Fatalf("MCPAPIKey(context7) = %q, want exact API key", got)
}
if got := file.MCPAPIKey("Context7"); got != "" {
t.Fatalf("MCPAPIKey case-insensitive lookup = %q, want empty", got)
}
})
}
}

func TestMCPTokenSchemaRejectsInvalidEntriesWithoutLeakingContent(t *testing.T) {
const sentinel = "MCP_TOKEN_SECRET_SENTINEL"
func TestStaticAPIKeySchemaRejectsInvalidEntriesWithoutLeakingContent(t *testing.T) {
const sentinel = "STATIC_API_KEY_SECRET_SENTINEL"
for name, contents := range map[string]string{
"unknown field": "mcp:\n valid:\n token: " + sentinel + "\n secret_key: " + sentinel + "\n",
"missing token": "mcp:\n valid: {}\n",
"non-string": "mcp:\n valid:\n token: 123\n",
"blank": "mcp:\n valid:\n token: ' '\n",
"invalid ID": "mcp:\n bad-id:\n token: " + sentinel + "\n",
"uppercase ID": "mcp:\n Context7:\n token: " + sentinel + "\n",
"unicode ID": "mcp:\n café:\n token: " + sentinel + "\n",
"empty ID": "mcp:\n '':\n token: " + sentinel + "\n",
"duplicate ID": "mcp:\n valid:\n token: " + sentinel + "\n valid:\n token: " + sentinel + "\n",
"unknown root": "mcp:\n valid:\n token: " + sentinel + "\nsecret_key: " + sentinel + "\n",
"malformed entry": "mcp:\n valid: " + sentinel + "\n",
"MCP token": "mcp:\n valid:\n token: " + sentinel + "\n",
"MCP token and API key": "mcp:\n valid:\n token: " + sentinel + "\n api_key: " + sentinel + "\n",
"search unknown field": "search:\n exa:\n api_key: " + sentinel + "\n secret_key: " + sentinel + "\n",
"missing API key": "search:\n exa: {}\n",
"duplicate API key": "mcp:\n valid:\n api_key: " + sentinel + "\n api_key: " + sentinel + "\n",
"null": "search:\n exa:\n api_key: null\n",
"non-string": "mcp:\n valid:\n api_key: 123\n",
"blank": "search:\n exa:\n api_key: ' '\n",
"invalid search ID": "search:\n bad-id:\n api_key: " + sentinel + "\n",
"invalid MCP ID": "mcp:\n bad-id:\n api_key: " + sentinel + "\n",
"duplicate ID": "search:\n exa:\n api_key: " + sentinel + "\n exa:\n api_key: " + sentinel + "\n",
"duplicate MCP ID": "mcp:\n valid:\n api_key: " + sentinel + "\n valid:\n api_key: " + sentinel + "\n",
"duplicate root": "search: {}\nsearch: {}\n",
"duplicate MCP root": "mcp: {}\nmcp: {}\n",
"unknown root": "search: {}\nsecret_key: " + sentinel + "\n",
} {
t.Run(name, func(t *testing.T) {
dir := t.TempDir()
Expand Down
Loading
Loading