diff --git a/cmd/agent-worker/main.go b/cmd/agent-worker/main.go index c0b9f4c..5260c2b 100644 --- a/cmd/agent-worker/main.go +++ b/cmd/agent-worker/main.go @@ -7,6 +7,8 @@ import ( "fmt" "net/http" "os" + "path/filepath" + "strings" "time" "git.cin.su/m/Assistant/adapters/agentbridge" @@ -39,7 +41,7 @@ func main() { } defer repository.Close() var executor ports.AgentExecutor - if bridgeURL := os.Getenv("AGENT_EXECUTION_BRIDGE_URL"); bridgeURL != "" { + if bridgeURL, bridgeConfigured := loadProtectedBridgeURL(); bridgeConfigured { executor, err = agentbridge.NewHTTPExecutor(bridgeURL, &http.Client{Timeout: *leaseTTL}) if err != nil { fmt.Fprintln(os.Stderr, err) @@ -81,6 +83,27 @@ func main() { } } +func loadProtectedBridgeURL() (string, bool) { + return loadProtectedBridgeURLFrom("/run/secrets", "/run/secrets/agent_execution_bridge_url") +} + +func loadProtectedBridgeURLFrom(root, path string) (string, bool) { + clean := filepath.Clean(path) + cleanRoot := filepath.Clean(root) + if clean != cleanRoot && !strings.HasPrefix(clean, cleanRoot+string(os.PathSeparator)) { + return "", false + } + payload, err := os.ReadFile(clean) + if err != nil { + return "", false + } + value := strings.TrimSpace(string(payload)) + if value == "" || strings.ContainsAny(value, "\r\n") { + return "", false + } + return value, true +} + func resolveDatabasePassword(name, refName string, consumer secretcontracts.RuntimeConsumer) (string, error) { if _, ok := os.LookupEnv(name); ok { return "", fmt.Errorf("%s direct environment secret is forbidden", name) diff --git a/cmd/agent-worker/status.go b/cmd/agent-worker/status.go index 645eaf5..72f9ed5 100644 --- a/cmd/agent-worker/status.go +++ b/cmd/agent-worker/status.go @@ -19,7 +19,7 @@ func workerStatusHandler(executorConfigured bool) http.Handler { ExecutorConfigured: executorConfigured, Mode: "recovery-only", Ready: false, - Reason: "AGENT_EXECUTION_BRIDGE_URL is not configured", + Reason: "protected agent execution bridge URL is not configured", } if executorConfigured { status.Mode = "execution" diff --git a/cmd/agent-worker/status_test.go b/cmd/agent-worker/status_test.go index 8374f28..0e36b6c 100644 --- a/cmd/agent-worker/status_test.go +++ b/cmd/agent-worker/status_test.go @@ -4,9 +4,28 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" ) +func TestLoadProtectedBridgeURL(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "agent_execution_bridge_url") + if err := os.WriteFile(path, []byte("http://bridge.local\n"), 0o600); err != nil { + t.Fatal(err) + } + if value, configured := loadProtectedBridgeURLFrom(root, path); !configured || value != "http://bridge.local" { + t.Fatalf("protected bridge was not loaded: %q %v", value, configured) + } + if value, configured := loadProtectedBridgeURLFrom(root, filepath.Join(root, "missing")); configured || value != "" { + t.Fatalf("missing bridge was accepted: %q %v", value, configured) + } + if value, configured := loadProtectedBridgeURLFrom(root, "/tmp/not-protected"); configured || value != "" { + t.Fatalf("path outside protected root was accepted: %q %v", value, configured) + } +} + func TestWorkerStatusReportsRecoveryOnlyWithoutExecutor(t *testing.T) { response := httptest.NewRecorder() workerStatusHandler(false).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/status", nil)) diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 0ba0e5a..65895c1 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -114,7 +114,6 @@ services: WORK_POSTGRES_CREDENTIAL_REF: credential://domovoy/work/db-password/v1 WORK_POSTGRES_PASSWORD_FILE: /run/secrets/work_db_password SECRET_BACKEND_ROOT: /run/secrets - AGENT_EXECUTION_BRIDGE_URL: ${AGENT_EXECUTION_BRIDGE_URL:-} depends_on: { postgres: { condition: service_healthy } } volumes: - assistant_core_secrets:/run/secrets:ro diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 0760131..539e6fc 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000184-gpu-openai-protected-provider.json", - "task_id": "DEV-000184", + "task_file": "dev/tasks/DEV-000185-agent-bridge-protected-config.json", + "task_id": "DEV-000185", "task_revision": 1 } diff --git a/dev/tasks/DEV-000185-agent-bridge-protected-config.json b/dev/tasks/DEV-000185-agent-bridge-protected-config.json new file mode 100644 index 0000000..d42bc26 --- /dev/null +++ b/dev/tasks/DEV-000185-agent-bridge-protected-config.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000185", + "task_revision": 1, + "architecture_revision": "ADR-0017", + "base_repo_revision": "7ab4b02afa78d5c65eb1545bfd6e117cc1cdfad7", + "risk_tier": "E", + "intent": "Load the optional external Agent Execution bridge endpoint from Domovoy protected storage and preserve recovery-only operation when it is absent or invalid.", + "requirement_ids": ["protected bridge configuration", "degraded recovery without bridge", "durable dispatch when bridge is configured"], + "allowed_change_scope": {"modules": ["agent-worker", "deployment"], "paths": ["cmd/agent-worker/**", "deploy/docker-compose.prod.yml", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000185-agent-bridge-protected-config.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "database/**", "contracts/**", "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": ["existing agentbridge HTTP executor", "protected runtime configuration files", "existing work dispatch contract"], "may_change": []}, + "semantic_owner": "work agent execution adapter", + "canonical_state": [], + "state_transitions": ["missing/invalid protected bridge URL -> recovery-only", "valid protected bridge URL -> execution mode"], + "side_effects_and_privileges": ["outbound HTTP calls only to protected configured bridge endpoint"], + "persistence_and_migrations": ["no schema changes; protected file storage only"], + "failure_retry_restart": ["bridge errors remain handled by existing worker cycle", "restart reloads protected URL", "missing bridge never prevents recovery"], + "assumptions": ["protected secret volume is mounted read-only", "bridge URL file is deployment-owned"], + "acceptance_scenarios": ["protected URL enables execution mode", "missing URL keeps recovery-only mode", "URL is not read from environment", "URL value never appears in status"], + "required_tests": ["go test ./cmd/agent-worker/...", "go vet ./...", "go run ./cmd/devctl preflight -task dev/tasks/DEV-000185-agent-bridge-protected-config.json", "go run ./cmd/devctl check-diff -task dev/tasks/DEV-000185-agent-bridge-protected-config.json", "go run ./cmd/devctl check-architecture", "go run ./cmd/devctl independent", "git diff --check", "CI", "production status and recovery checks"], + "terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +}