feat: unify gateway settings into Connection screen and add WS auth key
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 <noreply@anthropic.com>
This commit is contained in:
parent
7746cafe50
commit
38da89539e
14 changed files with 137 additions and 138 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<GatewaySettingsStore>().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()) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<GatewaySettings> =
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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{}{
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue