diff --git a/adapters/models/openai_compatible_local.go b/adapters/models/openai_compatible_local.go index 18944b7..a9dbc4b 100644 --- a/adapters/models/openai_compatible_local.go +++ b/adapters/models/openai_compatible_local.go @@ -22,9 +22,16 @@ import ( type OpenAICompatibleLocalAdapter struct { base *url.URL client *http.Client + apiKey string } func NewOpenAICompatibleLocalAdapter(raw string, transport http.RoundTripper) (*OpenAICompatibleLocalAdapter, error) { + return NewOpenAICompatibleLocalAdapterWithAPIKey(raw, "", transport) +} + +// NewOpenAICompatibleLocalAdapterWithAPIKey creates an adapter for a gateway +// whose bearer credential was resolved by the trusted runtime boundary. +func NewOpenAICompatibleLocalAdapterWithAPIKey(raw, apiKey string, transport http.RoundTripper) (*OpenAICompatibleLocalAdapter, error) { base, err := url.Parse(raw) if err != nil || base.Scheme != "http" || base.Host == "" || strings.TrimSuffix(base.Path, "/") != "/v1" || base.RawQuery != "" || base.Fragment != "" || base.User != nil { return nil, errors.New("invalid local compatible endpoint") @@ -32,7 +39,7 @@ func NewOpenAICompatibleLocalAdapter(raw string, transport http.RoundTripper) (* if transport == nil { transport = http.DefaultTransport } - return &OpenAICompatibleLocalAdapter{base: base, client: &http.Client{Transport: transport, Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}, nil + return &OpenAICompatibleLocalAdapter{base: base, client: &http.Client{Transport: transport, Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}, apiKey: strings.TrimSpace(apiKey)}, nil } type compatibleModelsResponse struct { @@ -145,6 +152,9 @@ func (a *OpenAICompatibleLocalAdapter) requestJSON(ctx context.Context, method, return 0, err } request.Header.Set("Accept", "application/json") + if a.apiKey != "" { + request.Header.Set("Authorization", "Bearer "+a.apiKey) + } if input != nil { request.Header.Set("Content-Type", "application/json") } diff --git a/adapters/models/openai_compatible_local_test.go b/adapters/models/openai_compatible_local_test.go new file mode 100644 index 0000000..da58dfc --- /dev/null +++ b/adapters/models/openai_compatible_local_test.go @@ -0,0 +1,42 @@ +package models + +import ( + "context" + "io" + "net/http" + "strings" + "testing" +) + +type captureRoundTripper struct { + authorization string +} + +func (t *captureRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + t.authorization = request.Header.Get("Authorization") + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"data":[]}`)), Header: make(http.Header), Request: request}, nil +} + +func TestOpenAICompatibleLocalAdapterUsesResolvedAPIKey(t *testing.T) { + transport := &captureRoundTripper{} + adapter, err := NewOpenAICompatibleLocalAdapterWithAPIKey("http://gateway/v1", "resolved-key", transport) + if err != nil { + t.Fatal(err) + } + _, _ = adapter.Discover(context.Background()) + if transport.authorization != "Bearer resolved-key" { + t.Fatalf("unexpected authorization header: %q", transport.authorization) + } +} + +func TestOpenAICompatibleLocalAdapterDoesNotSendAuthorizationWithoutKey(t *testing.T) { + transport := &captureRoundTripper{} + adapter, err := NewOpenAICompatibleLocalAdapter("http://gateway/v1", transport) + if err != nil { + t.Fatal(err) + } + _, _ = adapter.Discover(context.Background()) + if transport.authorization != "" { + t.Fatalf("unexpected authorization header") + } +} diff --git a/cmd/cognitive-worker/main.go b/cmd/cognitive-worker/main.go index fa04841..2f68029 100644 --- a/cmd/cognitive-worker/main.go +++ b/cmd/cognitive-worker/main.go @@ -10,6 +10,8 @@ import ( "git.cin.su/m/Assistant/contracts/capability" "git.cin.su/m/Assistant/contracts/cognitive" "git.cin.su/m/Assistant/contracts/identity" + secretcontracts "git.cin.su/m/Assistant/contracts/secrets" + platformsecrets "git.cin.su/m/Assistant/platform/secrets" "git.cin.su/m/Assistant/runtime/health" "io" "log" @@ -43,11 +45,21 @@ func main() { discoveries := map[string]providerRegistration{} var builtinLocal *providerRegistration externalRecovery := []providerRegistration{} - cpuAdapter, err := models.NewOpenAICompatibleLocalAdapter(envOr("LOCAL_CPU_PROVIDER_URL", "http://docker.cin.su:18082/v1"), nil) + cpuAPIKey, err := loadLocalCPUProviderAPIKey() if err != nil { log.Fatal(err) } - cpu := providerRegistration{ProviderInstanceID: "local-cpu-primary", Kind: "openai-compatible-chat-v1", EndpointRef: "local-cpu-primary-v1", AuthMode: capability.AuthNone, BillingMode: capability.BillingFree, DataPolicy: capability.DataPolicyLocalOnly, Provider: cpuAdapter, Discovery: cpuAdapter} + cpuAdapter, err := models.NewOpenAICompatibleLocalAdapterWithAPIKey(envOr("LOCAL_CPU_PROVIDER_URL", "http://docker.cin.su:18082/v1"), cpuAPIKey, nil) + if err != nil { + log.Fatal(err) + } + cpuAuthMode := capability.AuthNone + cpuCredentialRef := capability.CredentialRef("") + if cpuAPIKey != "" { + cpuAuthMode = capability.AuthKey + cpuCredentialRef = capability.CredentialRef(envOr("LOCAL_CPU_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-cpu/provider-api-key/v1")) + } + cpu := providerRegistration{ProviderInstanceID: "local-cpu-primary", Kind: "openai-compatible-chat-v1", EndpointRef: "local-cpu-primary-v1", CredentialRef: cpuCredentialRef, AuthMode: cpuAuthMode, BillingMode: capability.BillingFree, DataPolicy: capability.DataPolicyLocalOnly, Provider: cpuAdapter, Discovery: cpuAdapter} providers[cpu.Kind] = append(providers[cpu.Kind], cpu) discoveries["cpu"] = cpu if lifeboat, enabled, lifeboatErr := newLifeboatProvider(os.Getenv("LOCAL_LIFEBOAT_PROVIDER_URL")); lifeboatErr != nil { @@ -145,6 +157,25 @@ func refreshProviderDiscoveries(resolver bindingResolver, service discoveryServi } } +func loadLocalCPUProviderAPIKey() (string, error) { + if _, set := os.LookupEnv("LOCAL_CPU_PROVIDER_API_KEY"); set { + return "", fmt.Errorf("LOCAL_CPU_PROVIDER_API_KEY direct environment secret is forbidden; use LOCAL_CPU_PROVIDER_API_KEY_FILE") + } + fileName := strings.TrimSpace(os.Getenv("LOCAL_CPU_PROVIDER_API_KEY_FILE")) + if fileName == "" { + return "", nil + } + ref := strings.TrimSpace(os.Getenv("LOCAL_CPU_PROVIDER_CREDENTIAL_REF")) + if ref == "" { + return "", fmt.Errorf("LOCAL_CPU_PROVIDER_CREDENTIAL_REF is required when LOCAL_CPU_PROVIDER_API_KEY_FILE is configured") + } + resolver, err := platformsecrets.NewFileResolver(envOr("SECRET_BACKEND_ROOT", "/run/secrets")) + if err != nil { + return "", err + } + return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeProviderAPIKey, Consumer: secretcontracts.RuntimeConsumerCognitive, Path: fileName}) +} + func deterministicAdvisory(w http.ResponseWriter, r *http.Request) { request, ok := decodeAdvisoryRequest(w, r) if !ok { diff --git a/cmd/cognitive-worker/main_test.go b/cmd/cognitive-worker/main_test.go index 950f30c..4784697 100644 --- a/cmd/cognitive-worker/main_test.go +++ b/cmd/cognitive-worker/main_test.go @@ -1,6 +1,10 @@ package main -import "testing" +import ( + "os" + "path/filepath" + "testing" +) func TestLifeboatProviderIsOptionalAndLocalAdapterBacked(t *testing.T) { if _, enabled, err := newLifeboatProvider(""); err != nil || enabled { @@ -14,3 +18,28 @@ func TestLifeboatProviderIsOptionalAndLocalAdapterBacked(t *testing.T) { t.Fatalf("unexpected lifeboat registration: %+v", provider) } } + +func TestLoadLocalCPUProviderAPIKeyUsesProtectedFileReference(t *testing.T) { + t.Setenv("LOCAL_CPU_PROVIDER_API_KEY", "") + _ = os.Unsetenv("LOCAL_CPU_PROVIDER_API_KEY") + root := t.TempDir() + path := filepath.Join(root, "local_cpu_provider_api_key") + if err := os.WriteFile(path, []byte("resolved-key\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("LOCAL_CPU_PROVIDER_API_KEY_FILE", path) + t.Setenv("LOCAL_CPU_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-cpu/provider-api-key/v1") + t.Setenv("SECRET_BACKEND_ROOT", root) + key, err := loadLocalCPUProviderAPIKey() + if err != nil || key != "resolved-key" { + t.Fatalf("unexpected key resolution result: err=%v", err) + } +} + +func TestLoadLocalCPUProviderAPIKeyRejectsDirectEnvironmentSecret(t *testing.T) { + t.Setenv("LOCAL_CPU_PROVIDER_API_KEY", "must-not-be-used") + t.Setenv("LOCAL_CPU_PROVIDER_API_KEY_FILE", "") + if _, err := loadLocalCPUProviderAPIKey(); err == nil { + t.Fatal("expected direct environment secret to be rejected") + } +} diff --git a/contracts/secrets/types.go b/contracts/secrets/types.go index f1022b6..57946d2 100644 --- a/contracts/secrets/types.go +++ b/contracts/secrets/types.go @@ -9,6 +9,7 @@ type SecretPurpose string const ( SecretPurposeDatabasePassword SecretPurpose = "DATABASE_PASSWORD" + SecretPurposeProviderAPIKey SecretPurpose = "PROVIDER_API_KEY" ) // RuntimeConsumer identifies a trusted runtime allowed to resolve a credential. @@ -18,6 +19,7 @@ const ( RuntimeConsumerCore RuntimeConsumer = "core-runtime" RuntimeConsumerWorkWorker RuntimeConsumer = "agent-worker" RuntimeConsumerEpistemicsWorker RuntimeConsumer = "reflection-worker" + RuntimeConsumerCognitive RuntimeConsumer = "cognitive-worker" ) // SecretFileRef is bootstrap delivery metadata for the V1 protected local diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index fd03c0e..e5093a1 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000151-agent-worker-status-ui.json", - "task_id": "DEV-000151", + "task_file": "dev/tasks/DEV-000152-cognitive-provider-secret-reference.json", + "task_id": "DEV-000152", "task_revision": 1 } diff --git a/dev/tasks/DEV-000152-cognitive-provider-secret-reference.json b/dev/tasks/DEV-000152-cognitive-provider-secret-reference.json new file mode 100644 index 0000000..65b117b --- /dev/null +++ b/dev/tasks/DEV-000152-cognitive-provider-secret-reference.json @@ -0,0 +1,35 @@ +{ + "task_id": "DEV-000152", + "task_revision": 1, + "architecture_revision": "ADR-0010", + "base_repo_revision": "624c41d4f6dae8c8d341aec09485efbba78bc76f", + "risk_tier": "E", + "intent": "Enable cognitive-worker to authenticate the local OpenAI-compatible CPU gateway through a typed file-backed credential reference without exposing provider key material in environment, source, or logs.", + "requirement_ids": ["AUDIT-WP-003", "AUDIT-WP-201", "CONSTITUTION-1.3.28"], + "allowed_change_scope": { + "modules": ["capability", "cognitive"], + "paths": [ + "contracts/secrets/types.go", + "platform/secrets/**", + "adapters/models/openai_compatible_local.go", + "adapters/models/openai_compatible_local_test.go", + "cmd/cognitive-worker/main.go", + "cmd/cognitive-worker/main_test.go", + "dev/ACTIVE_TASK.json", + "dev/tasks/DEV-000152-cognitive-provider-secret-reference.json" + ] + }, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "cmd/devctl/**", ".gitea/**", "deploy/**", "database/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0}, + "contracts": {"consume": ["typed CredentialRef", "protected file-backed secret resolver"], "may_change": ["secret purpose and trusted runtime consumer enum values"]}, + "semantic_owner": "capability", + "canonical_state": ["provider credential material remains external to repository and database"], + "state_transitions": ["cognitive-worker sends a bearer credential only when a trusted file-backed reference resolves successfully"], + "side_effects_and_privileges": ["none; adapter receives resolved material from runtime bootstrap and never persists it"], + "persistence_and_migrations": ["no database migration"], + "failure_retry_restart": ["direct secret environment configuration fails closed", "missing reference or unreadable file fails startup", "no configured key preserves existing unauthenticated development behavior"], + "assumptions": ["production operators provision the gateway key into the protected secret volume separately; this task does not copy or reveal it"], + "acceptance_scenarios": ["resolved key becomes Authorization Bearer header", "no key sends no Authorization header", "direct environment secret is rejected", "secret value is absent from logs and repository"], + "required_tests": ["unit", "go test", "go vet", "architecture preflight", "check-diff", "git diff --check", "independent", "exact diff scope", "CI"], + "terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "BLOCKED_BY_ARCHITECTURE_GAP"] +}