From 38da89539ec063ffa128364320c510e820fa5928 Mon Sep 17 00:00:00 2001 From: Kohei Date: Sat, 28 Feb 2026 03:39:49 +0900 Subject: [PATCH] feat: unify gateway settings into Connection screen and add WS auth key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate gateway port/API key management into a single Connection screen that updates the server via config API, and separate WS auth from gateway auth by adding api_key to WebSocketConfig. - Remove GatewayConfig.Host (hardcode 127.0.0.1) - Add WebSocketConfig.APIKey with constant-time auth check on WS upgrade - Exclude gateway section from Backend Config schema UI - Refactor Android App Settings → Connection (port + API key only) - Fetch WS connection info dynamically from config API - Remove wsPort/wsUrl from GatewaySettings and WebSocketClient.apiKey Co-Authored-By: Claude Opus 4.6 --- .../main/java/io/clawdroid/ClawDroidApp.kt | 56 ++++++++++++++--- .../main/java/io/clawdroid/di/AppModule.kt | 8 +-- .../clawdroid/settings/AppSettingsScreen.kt | 53 ++++++++-------- .../settings/AppSettingsViewModel.kt | 55 ++++++++++------- .../settings/GatewaySettingsStoreImpl.kt | 3 - .../clawdroid/backend/api/GatewaySettings.kt | 2 - .../core/data/remote/WebSocketClient.kt | 10 +--- cmd/clawdroid/main.go | 4 +- pkg/channels/websocket.go | 8 +++ pkg/config/config.go | 3 +- pkg/config/config_test.go | 6 -- pkg/gateway/schema.go | 5 ++ pkg/gateway/server.go | 2 +- pkg/gateway/server_test.go | 60 ++++--------------- 14 files changed, 137 insertions(+), 138 deletions(-) diff --git a/android/app/src/main/java/io/clawdroid/ClawDroidApp.kt b/android/app/src/main/java/io/clawdroid/ClawDroidApp.kt index af06aa2e4..f8d1f1d4a 100644 --- a/android/app/src/main/java/io/clawdroid/ClawDroidApp.kt +++ b/android/app/src/main/java/io/clawdroid/ClawDroidApp.kt @@ -1,15 +1,22 @@ package io.clawdroid import android.app.Application +import android.util.Log import io.clawdroid.backend.api.GatewaySettingsStore +import io.clawdroid.backend.config.ConfigApiClient import io.clawdroid.backend.config.configModule import io.clawdroid.core.data.remote.WebSocketClient import io.clawdroid.di.appModule import io.clawdroid.receiver.NotificationHelper import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin +import java.net.URLEncoder class ClawDroidApp : Application() { override fun onCreate() { @@ -23,16 +30,51 @@ class ClawDroidApp : Application() { val koin = koinApp.koin val settingsStore: GatewaySettingsStore = koin.get() val wsClient: WebSocketClient = koin.get() + val configApiClient: ConfigApiClient = koin.get() val scope: CoroutineScope = koin.get() scope.launch { - settingsStore.settings.collect { s -> - if (wsClient.wsUrl != s.wsUrl || wsClient.apiKey != s.apiKey) { - wsClient.disconnect() - wsClient.wsUrl = s.wsUrl - wsClient.apiKey = s.apiKey - wsClient.connect() + // Only react when httpPort or apiKey actually change + settingsStore.settings + .map { it.httpPort to it.apiKey } + .distinctUntilChanged() + .collect { + // Fetch WS connection info from config API + val wsUrl = fetchWsUrl(configApiClient) + if (wsClient.wsUrl != wsUrl) { + wsClient.disconnect() + wsClient.wsUrl = wsUrl + wsClient.connect() + } } - } } } + + private suspend fun fetchWsUrl(configApiClient: ConfigApiClient): String { + return try { + val cfg = configApiClient.getConfig() + val ws = cfg["channels"]?.jsonObject?.get("websocket")?.jsonObject + if (ws != null) { + val host = ws["host"]?.jsonPrimitive?.content ?: "127.0.0.1" + val port = ws["port"]?.jsonPrimitive?.content ?: "18793" + val path = ws["path"]?.jsonPrimitive?.content ?: "/ws" + val wsApiKey = ws["api_key"]?.jsonPrimitive?.content ?: "" + buildString { + append("ws://$host:$port$path") + if (wsApiKey.isNotEmpty()) { + append("?api_key=${URLEncoder.encode(wsApiKey, "UTF-8")}") + } + } + } else { + DEFAULT_WS_URL + } + } catch (e: Exception) { + Log.w(TAG, "Failed to fetch WS config from API, using defaults", e) + DEFAULT_WS_URL + } + } + + companion object { + private const val TAG = "ClawDroidApp" + private const val DEFAULT_WS_URL = "ws://127.0.0.1:18793/ws" + } } diff --git a/android/app/src/main/java/io/clawdroid/di/AppModule.kt b/android/app/src/main/java/io/clawdroid/di/AppModule.kt index 996a92b4f..1b971384e 100644 --- a/android/app/src/main/java/io/clawdroid/di/AppModule.kt +++ b/android/app/src/main/java/io/clawdroid/di/AppModule.kt @@ -78,11 +78,7 @@ val appModule = module { val clientId = prefs.getString("client_id", null) ?: UUID.randomUUID().toString().also { prefs.edit().putString("client_id", it).apply() } - val gwSettings = get().settings.value - WebSocketClient(get(), get(), clientId).apply { - wsUrl = gwSettings.wsUrl - apiKey = gwSettings.apiKey - } + WebSocketClient(get(), get(), clientId) } // ImageFileStorage @@ -134,5 +130,5 @@ val appModule = module { // ViewModel viewModel { ChatViewModel(get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { SettingsViewModel(get(), get(), get()) } - viewModel { AppSettingsViewModel(get()) } + viewModel { AppSettingsViewModel(get(), get()) } } diff --git a/android/app/src/main/java/io/clawdroid/settings/AppSettingsScreen.kt b/android/app/src/main/java/io/clawdroid/settings/AppSettingsScreen.kt index 3c14cf7b5..5e1778555 100644 --- a/android/app/src/main/java/io/clawdroid/settings/AppSettingsScreen.kt +++ b/android/app/src/main/java/io/clawdroid/settings/AppSettingsScreen.kt @@ -59,7 +59,7 @@ fun AppSettingsScreen( ) { val uiState by viewModel.uiState.collectAsState() var apiKeyHidden by remember { mutableStateOf(true) } - val saveEnabled by remember { derivedStateOf { !uiState.hasErrors } } + val saveEnabled by remember { derivedStateOf { !uiState.hasErrors && !uiState.saving } } Box( modifier = Modifier @@ -92,7 +92,7 @@ fun AppSettingsScreen( containerColor = Color.Transparent, topBar = { TopAppBar( - title = { Text("App Settings") }, + title = { Text("Connection") }, colors = TopAppBarDefaults.topAppBarColors( containerColor = Color.Transparent, ), @@ -115,7 +115,7 @@ fun AppSettingsScreen( ), modifier = Modifier.padding(end = 8.dp), ) { - Text("Save") + Text(if (uiState.saving) "Saving…" else "Save") } }, ) @@ -135,10 +135,23 @@ fun AppSettingsScreen( color = NeonCyan, ) + OutlinedTextField( + value = uiState.httpPort, + onValueChange = { viewModel.onHttpPortChange(it) }, + label = { Text("Port", color = TextSecondary) }, + placeholder = { Text("18790", color = TextSecondary.copy(alpha = 0.5f)) }, + singleLine = true, + isError = uiState.httpPortError != null, + supportingText = uiState.httpPortError?.let { err -> { Text(err) } }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + colors = appSettingsFieldColors(), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( value = uiState.apiKey, onValueChange = { viewModel.onApiKeyChange(it) }, - label = { Text("Gateway API Key", color = TextSecondary) }, + label = { Text("API Key", color = TextSecondary) }, singleLine = true, visualTransformation = if (apiKeyHidden) PasswordVisualTransformation() else VisualTransformation.None, trailingIcon = { @@ -154,31 +167,13 @@ fun AppSettingsScreen( modifier = Modifier.fillMaxWidth(), ) - OutlinedTextField( - value = uiState.wsPort, - onValueChange = { viewModel.onWsPortChange(it) }, - label = { Text("Gateway WS Port", color = TextSecondary) }, - placeholder = { Text("18793", color = TextSecondary.copy(alpha = 0.5f)) }, - singleLine = true, - isError = uiState.wsPortError != null, - supportingText = uiState.wsPortError?.let { err -> { Text(err) } }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - colors = appSettingsFieldColors(), - modifier = Modifier.fillMaxWidth(), - ) - - OutlinedTextField( - value = uiState.httpPort, - onValueChange = { viewModel.onHttpPortChange(it) }, - label = { Text("Gateway HTTP Port", color = TextSecondary) }, - placeholder = { Text("18790", color = TextSecondary.copy(alpha = 0.5f)) }, - singleLine = true, - isError = uiState.httpPortError != null, - supportingText = uiState.httpPortError?.let { err -> { Text(err) } }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - colors = appSettingsFieldColors(), - modifier = Modifier.fillMaxWidth(), - ) + uiState.error?.let { error -> + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } } } } diff --git a/android/app/src/main/java/io/clawdroid/settings/AppSettingsViewModel.kt b/android/app/src/main/java/io/clawdroid/settings/AppSettingsViewModel.kt index 48675b66b..1e3dbabb1 100644 --- a/android/app/src/main/java/io/clawdroid/settings/AppSettingsViewModel.kt +++ b/android/app/src/main/java/io/clawdroid/settings/AppSettingsViewModel.kt @@ -4,20 +4,23 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.clawdroid.backend.api.GatewaySettings import io.clawdroid.backend.api.GatewaySettingsStore +import io.clawdroid.backend.config.ConfigApiClient import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject data class AppSettingsUiState( val apiKey: String = "", - val wsPort: String = "18793", val httpPort: String = "18790", + val saving: Boolean = false, + val error: String? = null, ) { - val wsPortError: String? get() = portError(wsPort) val httpPortError: String? get() = portError(httpPort) - val hasErrors: Boolean get() = wsPortError != null || httpPortError != null + val hasErrors: Boolean get() = httpPortError != null } private fun portError(value: String): String? { @@ -28,6 +31,7 @@ private fun portError(value: String): String? { class AppSettingsViewModel( private val settingsStore: GatewaySettingsStore, + private val configApiClient: ConfigApiClient, ) : ViewModel() { private val _uiState = MutableStateFlow(AppSettingsUiState()) @@ -37,43 +41,48 @@ class AppSettingsViewModel( val current = settingsStore.settings.value _uiState.value = AppSettingsUiState( apiKey = current.apiKey, - wsPort = current.wsPort.toString(), httpPort = current.httpPort.toString(), ) } fun onApiKeyChange(value: String) { - _uiState.update { it.copy(apiKey = value) } - } - - fun onWsPortChange(value: String) { - if (value.isEmpty() || value.toIntOrNull() != null) { - _uiState.update { it.copy(wsPort = value) } - } + _uiState.update { it.copy(apiKey = value, error = null) } } fun onHttpPortChange(value: String) { if (value.isEmpty() || value.toIntOrNull() != null) { - _uiState.update { it.copy(httpPort = value) } + _uiState.update { it.copy(httpPort = value, error = null) } } } fun save(onComplete: () -> Unit) { viewModelScope.launch { val state = _uiState.value - if (state.hasErrors) return@launch + if (state.hasErrors || state.saving) return@launch + + _uiState.update { it.copy(saving = true, error = null) } + val defaults = GatewaySettings() - fun validPort(raw: String, fallback: Int): Int { - val port = raw.toIntOrNull() ?: return fallback - return if (port in 1..65535) port else fallback + val newPort = state.httpPort.toIntOrNull()?.takeIf { it in 1..65535 } ?: defaults.httpPort + val newKey = state.apiKey + + // Send update via config API using current (old) connection settings + val payload = buildJsonObject { + put("gateway", buildJsonObject { + put("port", JsonPrimitive(newPort)) + put("api_key", JsonPrimitive(newKey)) + }) + } + + try { + configApiClient.saveConfig(payload) + // Persist new values locally after remote success + settingsStore.update(GatewaySettings(httpPort = newPort, apiKey = newKey)) + _uiState.update { it.copy(saving = false) } + onComplete() + } catch (e: Exception) { + _uiState.update { it.copy(saving = false, error = e.message ?: "Save failed") } } - val settings = GatewaySettings( - wsPort = validPort(state.wsPort, defaults.wsPort), - httpPort = validPort(state.httpPort, defaults.httpPort), - apiKey = state.apiKey, - ) - settingsStore.update(settings) - onComplete() } } } diff --git a/android/app/src/main/java/io/clawdroid/settings/GatewaySettingsStoreImpl.kt b/android/app/src/main/java/io/clawdroid/settings/GatewaySettingsStoreImpl.kt index 93255cec8..50c19c9fe 100644 --- a/android/app/src/main/java/io/clawdroid/settings/GatewaySettingsStoreImpl.kt +++ b/android/app/src/main/java/io/clawdroid/settings/GatewaySettingsStoreImpl.kt @@ -21,7 +21,6 @@ class GatewaySettingsStoreImpl( ) : GatewaySettingsStore { private object Keys { - val WS_PORT = intPreferencesKey("ws_port") val HTTP_PORT = intPreferencesKey("http_port") val API_KEY = stringPreferencesKey("api_key") } @@ -29,7 +28,6 @@ class GatewaySettingsStoreImpl( override val settings: StateFlow = context.gatewayDataStore.data.map { prefs -> GatewaySettings( - wsPort = prefs[Keys.WS_PORT] ?: DEFAULT.wsPort, httpPort = prefs[Keys.HTTP_PORT] ?: DEFAULT.httpPort, apiKey = prefs[Keys.API_KEY] ?: DEFAULT.apiKey, ) @@ -41,7 +39,6 @@ class GatewaySettingsStoreImpl( override suspend fun update(settings: GatewaySettings) { context.gatewayDataStore.edit { prefs -> - prefs[Keys.WS_PORT] = settings.wsPort prefs[Keys.HTTP_PORT] = settings.httpPort prefs[Keys.API_KEY] = settings.apiKey } diff --git a/android/backend/api/src/main/java/io/clawdroid/backend/api/GatewaySettings.kt b/android/backend/api/src/main/java/io/clawdroid/backend/api/GatewaySettings.kt index 610e6040d..dd54f6164 100644 --- a/android/backend/api/src/main/java/io/clawdroid/backend/api/GatewaySettings.kt +++ b/android/backend/api/src/main/java/io/clawdroid/backend/api/GatewaySettings.kt @@ -1,10 +1,8 @@ package io.clawdroid.backend.api data class GatewaySettings( - val wsPort: Int = 18793, val httpPort: Int = 18790, val apiKey: String = "", ) { - val wsUrl: String get() = "ws://127.0.0.1:$wsPort/ws" val httpBaseUrl: String get() = "http://127.0.0.1:$httpPort" } diff --git a/android/core/data/src/main/java/io/clawdroid/core/data/remote/WebSocketClient.kt b/android/core/data/src/main/java/io/clawdroid/core/data/remote/WebSocketClient.kt index 65c512cf3..0eb0a4cee 100644 --- a/android/core/data/src/main/java/io/clawdroid/core/data/remote/WebSocketClient.kt +++ b/android/core/data/src/main/java/io/clawdroid/core/data/remote/WebSocketClient.kt @@ -22,7 +22,7 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import java.net.URLEncoder + class WebSocketClient( private val client: HttpClient, @@ -42,7 +42,6 @@ class WebSocketClient( private val json = Json { ignoreUnknownKeys = true } @Volatile var wsUrl: String = "ws://127.0.0.1:18793/ws" - @Volatile var apiKey: String = "" fun connect() { if (connectJob?.isActive == true) return @@ -52,11 +51,8 @@ class WebSocketClient( try { _connectionState.value = ConnectionState.CONNECTING val currentWsUrl = wsUrl - val currentApiKey = apiKey - val url = buildString { - append("$currentWsUrl?client_id=$clientId&client_type=$clientType") - if (currentApiKey.isNotEmpty()) append("&api_key=${URLEncoder.encode(currentApiKey, "UTF-8")}") - } + val separator = if ('?' in currentWsUrl) '&' else '?' + val url = "${currentWsUrl}${separator}client_id=$clientId&client_type=$clientType" client.webSocket(url) { session = this _connectionState.value = ConnectionState.CONNECTED diff --git a/cmd/clawdroid/main.go b/cmd/clawdroid/main.go index 46a3c2d45..37f1b5665 100644 --- a/cmd/clawdroid/main.go +++ b/cmd/clawdroid/main.go @@ -492,7 +492,7 @@ func gatewayCmd() { fmt.Printf("Error starting gateway HTTP server: %v\n", err) os.Exit(1) } - fmt.Printf("✓ Config API started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port) + fmt.Printf("✓ Config API started on 127.0.0.1:%d\n", cfg.Gateway.Port) // Setup cron tool and service cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.DataPath(), cfg.Agents.Defaults.RestrictToWorkspace, cfg.Tools.Exec.Enabled) @@ -539,7 +539,7 @@ func gatewayCmd() { fmt.Println("⚠ Warning: No channels enabled") } - fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port) + fmt.Printf("✓ Gateway started on 127.0.0.1:%d\n", cfg.Gateway.Port) fmt.Println("Press Ctrl+C to stop") ctx, cancel := context.WithCancel(context.Background()) diff --git a/pkg/channels/websocket.go b/pkg/channels/websocket.go index b25de2016..7190a6e26 100644 --- a/pkg/channels/websocket.go +++ b/pkg/channels/websocket.go @@ -2,6 +2,7 @@ package channels import ( "context" + "crypto/subtle" "encoding/json" "fmt" "net/http" @@ -198,6 +199,13 @@ func (c *WebSocketChannel) maybeBroadcast(msg bus.OutboundMessage, clientType st } func (c *WebSocketChannel) handleWS(w http.ResponseWriter, r *http.Request) { + if key := c.config.APIKey; key != "" { + if subtle.ConstantTimeCompare([]byte(r.URL.Query().Get("api_key")), []byte(key)) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + } + conn, err := c.upgrader.Upgrade(w, r, nil) if err != nil { logger.ErrorCF("websocket", "Upgrade failed", map[string]interface{}{ diff --git a/pkg/config/config.go b/pkg/config/config.go index ef2b65eae..5002e0559 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -124,6 +124,7 @@ type WebSocketConfig struct { Host string `json:"host" label:"Host" env:"CLAWDROID_CHANNELS_WEBSOCKET_HOST"` Port int `json:"port" label:"Port" env:"CLAWDROID_CHANNELS_WEBSOCKET_PORT"` Path string `json:"path" label:"Path" env:"CLAWDROID_CHANNELS_WEBSOCKET_PATH"` + APIKey string `json:"api_key" label:"API Key" env:"CLAWDROID_CHANNELS_WEBSOCKET_API_KEY"` AllowFrom FlexibleStringSlice `json:"allow_from" label:"Allow From" env:"CLAWDROID_CHANNELS_WEBSOCKET_ALLOW_FROM"` } @@ -138,7 +139,6 @@ type RateLimitsConfig struct { } type GatewayConfig struct { - Host string `json:"host" label:"Host" env:"CLAWDROID_GATEWAY_HOST"` Port int `json:"port" label:"Port" env:"CLAWDROID_GATEWAY_PORT"` APIKey string `json:"api_key" label:"API Key" env:"CLAWDROID_GATEWAY_API_KEY"` } @@ -249,7 +249,6 @@ func DefaultConfig() *Config { }, }, Gateway: GatewayConfig{ - Host: "127.0.0.1", Port: 18790, }, Tools: ToolsConfig{ diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index f0eed82dd..dbad94020 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -67,9 +67,6 @@ func TestDefaultConfig_Temperature(t *testing.T) { func TestDefaultConfig_Gateway(t *testing.T) { cfg := DefaultConfig() - if cfg.Gateway.Host != "127.0.0.1" { - t.Error("Gateway host should have default value") - } if cfg.Gateway.Port == 0 { t.Error("Gateway port should have default value") } @@ -207,9 +204,6 @@ func TestConfig_Complete(t *testing.T) { if cfg.Agents.Defaults.MaxToolIterations == 0 { t.Error("MaxToolIterations should not be zero") } - if cfg.Gateway.Host != "127.0.0.1" { - t.Error("Gateway host should have default value") - } if cfg.Gateway.Port == 0 { t.Error("Gateway port should have default value") } diff --git a/pkg/gateway/schema.go b/pkg/gateway/schema.go index 268bc3660..27ff4e565 100644 --- a/pkg/gateway/schema.go +++ b/pkg/gateway/schema.go @@ -65,6 +65,11 @@ func BuildSchema(defaultCfg *config.Config) SchemaResponse { continue } + // Gateway is managed via the Connection screen, not the Backend Config UI. + if jsonTag == "gateway" { + continue + } + section := SchemaSection{ Key: jsonTag, Label: labelTag(field), diff --git a/pkg/gateway/server.go b/pkg/gateway/server.go index 0f24113d4..54afed39d 100644 --- a/pkg/gateway/server.go +++ b/pkg/gateway/server.go @@ -33,7 +33,7 @@ func (s *Server) Start() error { mux.HandleFunc("GET /api/config", s.authMiddleware(s.handleGetConfig)) mux.HandleFunc("PUT /api/config", s.authMiddleware(s.handlePutConfig)) - addr := fmt.Sprintf("%s:%d", s.cfg.Gateway.Host, s.cfg.Gateway.Port) + addr := fmt.Sprintf("127.0.0.1:%d", s.cfg.Gateway.Port) s.server = &http.Server{ Addr: addr, Handler: mux, diff --git a/pkg/gateway/server_test.go b/pkg/gateway/server_test.go index bf5440fb7..763107bf6 100644 --- a/pkg/gateway/server_test.go +++ b/pkg/gateway/server_test.go @@ -57,7 +57,6 @@ func TestServerStop_NoServer_NoError(t *testing.T) { func TestServerStart_RegistersRoutesAndAuth(t *testing.T) { cfg := config.DefaultConfig() - cfg.Gateway.Host = "127.0.0.1" cfg.Gateway.Port = -1 // invalid port prevents real listen but Start still builds handler cfg.Gateway.APIKey = "test-key" @@ -106,9 +105,9 @@ func TestServerStart_RegistersRoutesAndAuth(t *testing.T) { func TestBuildSchema_SectionCount(t *testing.T) { schema := BuildSchema(config.DefaultConfig()) - // Config has exported fields: LLM, Agents, Channels, Gateway, Tools, Heartbeat, RateLimits - if len(schema.Sections) < 7 { - t.Errorf("expected at least 7 sections, got %d", len(schema.Sections)) + // Config has exported fields: LLM, Agents, Channels, Tools, Heartbeat, RateLimits (gateway excluded) + if len(schema.Sections) < 6 { + t.Errorf("expected at least 6 sections, got %d", len(schema.Sections)) } } @@ -342,8 +341,6 @@ func TestBuildSchema_DefaultValues(t *testing.T) { {"agents", "defaults.max_tokens", float64(8192)}, // JSON numbers → float64 {"agents", "defaults.context_window", float64(128000)}, {"agents", "defaults.restrict_to_workspace", true}, - {"gateway", "host", "127.0.0.1"}, - {"gateway", "port", float64(18790)}, {"heartbeat", "enabled", true}, {"heartbeat", "interval", float64(30)}, } @@ -387,8 +384,8 @@ func TestHandleGetSchema_Response(t *testing.T) { t.Fatalf("failed to decode response: %v", err) } - if len(schema.Sections) < 7 { - t.Errorf("expected at least 7 sections, got %d", len(schema.Sections)) + if len(schema.Sections) < 6 { + t.Errorf("expected at least 6 sections, got %d", len(schema.Sections)) } // Verify each section has at least one field @@ -451,7 +448,6 @@ func TestHandleGetConfig_NonSecretValues(t *testing.T) { cfg := config.DefaultConfig() cfg.LLM.Model = "test-model" cfg.LLM.BaseURL = "https://example.com" - cfg.Gateway.Host = "0.0.0.0" cfg.Gateway.Port = 9999 s := newTestServer(cfg) @@ -475,9 +471,6 @@ func TestHandleGetConfig_NonSecretValues(t *testing.T) { } gw := result["gateway"].(map[string]interface{}) - if gw["host"] != "0.0.0.0" { - t.Errorf("gateway host = %v, want %q", gw["host"], "0.0.0.0") - } if gw["port"] != float64(9999) { t.Errorf("gateway port = %v, want %v", gw["port"], 9999) } @@ -723,7 +716,6 @@ func TestHandlePutConfig_SaveError_500(t *testing.T) { func TestHandlePutConfig_PartialUpdatePreservesOtherSections(t *testing.T) { cfg := config.DefaultConfig() cfg.LLM.Model = "original-model" - cfg.Gateway.Host = "10.0.0.1" cfg.Gateway.Port = 12345 cfg.Channels.Telegram.Enabled = true cfg.Heartbeat.Interval = 60 @@ -751,9 +743,6 @@ func TestHandlePutConfig_PartialUpdatePreservesOtherSections(t *testing.T) { if saved.LLM.Model != "new-model" { t.Errorf("LLM model = %q, want %q", saved.LLM.Model, "new-model") } - if saved.Gateway.Host != "10.0.0.1" { - t.Errorf("Gateway host = %q, want %q (should be preserved)", saved.Gateway.Host, "10.0.0.1") - } if saved.Gateway.Port != 12345 { t.Errorf("Gateway port = %d, want %d (should be preserved)", saved.Gateway.Port, 12345) } @@ -1200,7 +1189,6 @@ func TestBuildSchema_SectionLabels(t *testing.T) { func TestServerRouting_PutConfig_AuthRequired(t *testing.T) { cfg := config.DefaultConfig() - cfg.Gateway.Host = "127.0.0.1" cfg.Gateway.Port = -1 cfg.Gateway.APIKey = "route-test-key" @@ -2064,7 +2052,6 @@ func TestHandlePutConfig_ThenGetRoundTrip(t *testing.T) { func TestServerStop_CalledTwice(t *testing.T) { cfg := config.DefaultConfig() - cfg.Gateway.Host = "127.0.0.1" cfg.Gateway.Port = -1 s := NewServer(cfg, "/tmp/config.json", nil) @@ -2085,7 +2072,6 @@ func TestServerStop_CalledTwice(t *testing.T) { func TestServerRouting_UnsupportedMethods(t *testing.T) { cfg := config.DefaultConfig() - cfg.Gateway.Host = "127.0.0.1" cfg.Gateway.Port = -1 s := NewServer(cfg, "/tmp/config.json", nil) @@ -2107,7 +2093,6 @@ func TestServerRouting_UnsupportedMethods(t *testing.T) { func TestServerRouting_UnknownPath(t *testing.T) { cfg := config.DefaultConfig() - cfg.Gateway.Host = "127.0.0.1" cfg.Gateway.Port = -1 s := NewServer(cfg, "/tmp/config.json", nil) @@ -2168,7 +2153,6 @@ func TestAuthMiddleware_TokenWithWhitespace_403(t *testing.T) { func TestServerRouting_SchemaAuthRequired(t *testing.T) { cfg := config.DefaultConfig() - cfg.Gateway.Host = "127.0.0.1" cfg.Gateway.Port = -1 cfg.Gateway.APIKey = "schema-auth-key" @@ -2336,38 +2320,14 @@ func TestBuildSchema_RateLimitsSectionFields(t *testing.T) { } } -// --- #30: gateway section all fields in schema --- +// --- #30: gateway section excluded from schema --- -func TestBuildSchema_GatewaySectionFields(t *testing.T) { +func TestBuildSchema_GatewaySectionExcluded(t *testing.T) { schema := BuildSchema(config.DefaultConfig()) - var gw *SchemaSection - for i := range schema.Sections { - if schema.Sections[i].Key == "gateway" { - gw = &schema.Sections[i] - break - } - } - if gw == nil { - t.Fatal("gateway section not found") - } - - keySet := map[string]bool{} - for _, f := range gw.Fields { - keySet[f.Key] = true - } - - wantKeys := []string{"host", "port", "api_key"} - for _, k := range wantKeys { - if !keySet[k] { - t.Errorf("gateway field %q not found", k) - } - } - - // api_key should be secret - for _, f := range gw.Fields { - if f.Key == "api_key" && !f.Secret { - t.Error("gateway api_key should be marked as secret") + for _, sec := range schema.Sections { + if sec.Key == "gateway" { + t.Error("gateway section should be excluded from schema") } } }