diff --git a/cmd/cognitive-worker/main.go b/cmd/cognitive-worker/main.go index dd1d4e7..17c5b3a 100644 --- a/cmd/cognitive-worker/main.go +++ b/cmd/cognitive-worker/main.go @@ -17,6 +17,7 @@ import ( "log" "net/http" "os" + "path/filepath" "strings" "time" ) @@ -92,14 +93,20 @@ func main() { discoveries["lifeboat"] = lifeboat builtinLocal = &lifeboat } - gpuAdapter, err := models.NewOllamaAdapter(envOr("LOCAL_GPU_PROVIDER_URL", "http://192.168.220.91:11434"), nil) - if err != nil { - log.Fatal(err) + gpuURL, gpuKey, gpuConfigured, gpuErr := loadProtectedGPUConfiguration() + if gpuErr != nil { + log.Printf("optional GPU provider disabled: %v", gpuErr) + } else if gpuConfigured { + gpuAdapter, adapterErr := models.NewOpenAICompatibleLocalAdapterWithAPIKey(gpuURL, gpuKey, nil) + if adapterErr != nil { + log.Printf("optional GPU provider disabled: %v", adapterErr) + } else { + gpu := providerRegistration{ProviderInstanceID: "local-gpu-opportunistic", Kind: "openai-compatible-chat-v1", EndpointRef: "local-gpu-opportunistic-v1", CredentialRef: "credential://domovoy/gpu/provider-api-key/v1", AuthMode: capability.AuthKey, BillingMode: capability.BillingFree, DataPolicy: capability.DataPolicyExternalMessageOnly, Provider: gpuAdapter, Discovery: gpuAdapter} + providers[gpu.Kind] = append(providers[gpu.Kind], gpu) + discoveries["gpu"] = gpu + externalRecovery = append(externalRecovery, gpu) + } } - gpu := providerRegistration{ProviderInstanceID: "local-gpu-opportunistic", Kind: "ollama-v1", EndpointRef: "local-gpu-opportunistic-v1", AuthMode: capability.AuthNone, BillingMode: capability.BillingFree, DataPolicy: capability.DataPolicyExternalMessageOnly, Provider: gpuAdapter, Discovery: gpuAdapter} - providers[gpu.Kind] = append(providers[gpu.Kind], gpu) - discoveries["gpu"] = gpu - externalRecovery = append(externalRecovery, gpu) if openAIProvider != nil { kind := capability.ProviderKind(envOr("OPENAI_PROVIDER_KIND", "openai-responses-v1")) providers[kind] = append(providers[kind], providerRegistration{ProviderInstanceID: "openai-bootstrap", Kind: kind, EndpointRef: capability.EndpointBindingRef(envOr("OPENAI_ENDPOINT_REF", "openai-responses-default")), CredentialRef: capability.CredentialRef(envOr("OPENAI_CREDENTIAL_REF", "openai-advisory-v1")), AuthMode: capability.AuthKey, BillingMode: capability.BillingPerInput, DataPolicy: capability.DataPolicyExternalMessageOnly, Provider: openAIProvider}) @@ -137,17 +144,24 @@ func main() { if mediumURL != "" { readinessProbes = append(readinessProbes, mediumHealthProbe) } - readinessProbes = append(readinessProbes, - health.HTTPProbe(readinessClient, lifeboatURL), - health.OptionalHTTPProbe(readinessClient, strings.TrimRight(envOr("LOCAL_GPU_PROVIDER_URL", "http://192.168.220.91:11434"), "/")+"/api/tags"), - ) - mux.Handle("/readyz", health.ReadinessHandler{Runtime: "cognitive-worker", Probes: readinessProbes}) - mux.Handle("/v1/fleet/readiness", fleetReadinessHandler(fleetClient, preferredFleet, []fleetEndpoint{ + readinessProbes = append(readinessProbes, health.HTTPProbe(readinessClient, lifeboatURL)) + if gpuConfigured { + gpuHeaders := map[string]string{"Authorization": "Bearer " + gpuKey} + gpuProbe := health.OptionalHTTPProbeWithHeaders(readinessClient, strings.TrimRight(gpuURL, "/")+"/models", gpuHeaders) + gpuProbe.Timeout = 10 * time.Second + readinessProbes = append(readinessProbes, gpuProbe) + } + + fleetEndpoints := []fleetEndpoint{ {ProviderID: "local-cpu-primary", ModelID: "local-cpu-model", Tier: capability.FleetTierFast, URL: cpuEndpoint, Headers: cpuFleetHeaders, Client: &http.Client{Timeout: 10 * time.Second}}, {ProviderID: "local-medium-primary", ModelID: "local-medium-model", Tier: capability.FleetTierMedium, URL: mediumEndpoint, Headers: mediumHeaders, Client: mediumReadinessClient}, - {ProviderID: "local-gpu-opportunistic", ModelID: "gpu-opportunistic", Tier: capability.FleetTierHeavy, URL: strings.TrimRight(envOr("LOCAL_GPU_PROVIDER_URL", "http://192.168.220.91:11434"), "/") + "/api/tags"}, {ProviderID: "cognitive-lifeboat", ModelID: "qwen3-8b-lifeboat", Tier: capability.FleetTierLifeboat, URL: lifeboatURL}, - })) + } + if gpuConfigured { + fleetEndpoints = append(fleetEndpoints, fleetEndpoint{ProviderID: "local-gpu-opportunistic", ModelID: "gpu-opportunistic", Tier: capability.FleetTierHeavy, URL: strings.TrimRight(gpuURL, "/") + "/models", Headers: map[string]string{"Authorization": "Bearer " + gpuKey}}) + } + mux.Handle("/readyz", health.ReadinessHandler{Runtime: "cognitive-worker", Probes: readinessProbes}) + mux.Handle("/v1/fleet/readiness", fleetReadinessHandler(fleetClient, preferredFleet, fleetEndpoints)) mux.HandleFunc("POST /v1/proposals/deterministic", deterministicProposal) mux.HandleFunc("POST /v1/advisory-policy", gateway.policyHandler) mux.HandleFunc("POST /v1/advisories", gateway.advisoryHandler) @@ -244,6 +258,46 @@ func loadLocalCPUProviderAPIKey() (string, error) { return loadProviderAPIKey("LOCAL_CPU_PROVIDER_API_KEY", "LOCAL_CPU_PROVIDER_API_KEY_FILE", "LOCAL_CPU_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-cpu/provider-api-key/v1") } +const ( + gpuProviderURLFile = "/run/secrets/llm_gpu_gateway_url" + gpuProviderKeyFile = "/run/secrets/llm_gpu_gateway_api_key" +) + +func loadProtectedGPUConfiguration() (string, string, bool, error) { + urlValue, err := readProtectedRuntimeValue(gpuProviderURLFile) + if errors.Is(err, os.ErrNotExist) { + return "", "", false, nil + } + if err != nil { + return "", "", false, err + } + key, err := readProtectedRuntimeValue(gpuProviderKeyFile) + if errors.Is(err, os.ErrNotExist) { + return "", "", false, fmt.Errorf("GPU provider key is not configured") + } + if err != nil { + return "", "", false, err + } + return urlValue, key, true, nil +} + +func readProtectedRuntimeValue(path string) (string, error) { + const root = "/run/secrets" + clean := filepath.Clean(path) + if clean != root && !strings.HasPrefix(clean, root+string(os.PathSeparator)) { + return "", fmt.Errorf("protected runtime path outside secret root") + } + payload, err := os.ReadFile(clean) + if err != nil { + return "", err + } + value := strings.TrimSpace(string(payload)) + if value == "" || strings.ContainsAny(value, "\r\n") { + return "", fmt.Errorf("protected runtime value is empty or multiline") + } + return value, nil +} + 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 8e3f8ab..4ccce5a 100644 --- a/cmd/cognitive-worker/main_test.go +++ b/cmd/cognitive-worker/main_test.go @@ -68,3 +68,9 @@ func TestLoadProviderAPIKeyRejectsMediumDirectEnvironmentSecret(t *testing.T) { t.Fatal("expected direct medium environment secret to be rejected") } } + +func TestReadProtectedRuntimeValueRejectsPathOutsideSecretRoot(t *testing.T) { + if _, err := readProtectedRuntimeValue("/tmp/not-a-domovoy-secret"); err == nil { + t.Fatal("expected protected path rejection") + } +} diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index b99cae5..0ba0e5a 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -218,7 +218,6 @@ services: LOCAL_MEDIUM_PROVIDER_API_KEY_FILE: ${LOCAL_MEDIUM_PROVIDER_API_KEY_FILE:-} LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF: ${LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF:-credential://domovoy/local-medium/provider-api-key/v1} LOCAL_LIFEBOAT_PROVIDER_URL: http://cognitive-lifeboat:8086/v1 - LOCAL_GPU_PROVIDER_URL: http://192.168.220.91:11434 COGNITIVE_OWNER_PRINCIPAL_ID: domovoy-creator-principal COGNITIVE_ALLOW_CANARY: "true" depends_on: [core-runtime, cognitive-lifeboat] diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index df51977..0760131 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000182-model-rating-and-adaptive-routing.json", - "task_id": "DEV-000182", + "task_file": "dev/tasks/DEV-000184-gpu-openai-protected-provider.json", + "task_id": "DEV-000184", "task_revision": 1 } diff --git a/dev/tasks/DEV-000184-gpu-openai-protected-provider.json b/dev/tasks/DEV-000184-gpu-openai-protected-provider.json new file mode 100644 index 0000000..cce4ca4 --- /dev/null +++ b/dev/tasks/DEV-000184-gpu-openai-protected-provider.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000184", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "16a0e8c4924a2e644294e12f048e747c00f940b9", + "risk_tier": "E", + "intent": "Connect an optional OpenAI-compatible GPU provider through protected Domovoy storage without literal provider credentials or hardwired environment configuration, while preserving CPU/Lifeboat degraded operation.", + "requirement_ids": ["GPU provider endpoint is authenticated through protected storage", "GPU absence or invalid configuration does not stop cognitive-worker", "fleet readiness and discovery use OpenAI-compatible /v1/models", "CPU and Lifeboat remain available fallback paths"], + "allowed_change_scope": {"modules": ["cognitive-worker", "deployment"], "paths": ["cmd/cognitive-worker/**", "deploy/docker-compose.prod.yml", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000184-gpu-openai-protected-provider.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "database/**", "contracts/**", "modules/**", "security/**", ".gitea/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 1, "new_third_party_dependencies": 0, "new_database_owner_roles": 0}, + "contracts": {"consume": ["protected Domovoy secret files", "OpenAI-compatible /v1/models and /v1/chat/completions", "existing cognitive gateway and fleet readiness contracts"], "may_change": []}, + "semantic_owner": "capability provider adapter projection", + "canonical_state": [], + "state_transitions": ["missing/invalid protected GPU config -> provider omitted and degraded readiness", "valid config -> authenticated discovery/readiness and optional heavy-tier candidate"], + "side_effects_and_privileges": ["outbound authenticated requests to the explicitly configured GPU endpoint; no credentials forwarded to Core or user responses"], + "persistence_and_migrations": ["no schema changes; protected file storage only"], + "failure_retry_restart": ["GPU timeout/unavailability falls back through existing router to CPU/Lifeboat", "restart reloads protected files", "invalid config disables only optional GPU provider"], + "assumptions": ["the supplied endpoint implements OpenAI-compatible /v1/models and /v1/chat/completions", "the protected secret volume is mounted read-only into cognitive-worker", "docker-test remains prohibited"], + "acceptance_scenarios": ["GPU /v1/models succeeds with protected key", "GPU key never appears in env/logs/responses", "GPU discovery creates provider-neutral records", "fleet readiness reports GPU heavy tier only when configured", "stopping GPU leaves CPU/Lifeboat ready", "chat remains available without GPU files"], + "required_tests": ["go test ./cmd/cognitive-worker ./adapters/models", "go vet ./...", "go run ./cmd/devctl preflight -task dev/tasks/DEV-000184-gpu-openai-protected-provider.json", "go run ./cmd/devctl check-diff -task dev/tasks/DEV-000184-gpu-openai-protected-provider.json", "go run ./cmd/devctl check-architecture", "go run ./cmd/devctl/independent", "git diff --check", "CI", "production protected-key models/readiness/live fallback checks"], + "terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +}