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:
Kohei 2026-02-28 03:39:49 +09:00
parent 7746cafe50
commit 38da89539e
14 changed files with 137 additions and 138 deletions

View file

@ -1,15 +1,22 @@
package io.clawdroid package io.clawdroid
import android.app.Application import android.app.Application
import android.util.Log
import io.clawdroid.backend.api.GatewaySettingsStore import io.clawdroid.backend.api.GatewaySettingsStore
import io.clawdroid.backend.config.ConfigApiClient
import io.clawdroid.backend.config.configModule import io.clawdroid.backend.config.configModule
import io.clawdroid.core.data.remote.WebSocketClient import io.clawdroid.core.data.remote.WebSocketClient
import io.clawdroid.di.appModule import io.clawdroid.di.appModule
import io.clawdroid.receiver.NotificationHelper import io.clawdroid.receiver.NotificationHelper
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import java.net.URLEncoder
class ClawDroidApp : Application() { class ClawDroidApp : Application() {
override fun onCreate() { override fun onCreate() {
@ -23,16 +30,51 @@ class ClawDroidApp : Application() {
val koin = koinApp.koin val koin = koinApp.koin
val settingsStore: GatewaySettingsStore = koin.get() val settingsStore: GatewaySettingsStore = koin.get()
val wsClient: WebSocketClient = koin.get() val wsClient: WebSocketClient = koin.get()
val configApiClient: ConfigApiClient = koin.get()
val scope: CoroutineScope = koin.get() val scope: CoroutineScope = koin.get()
scope.launch { scope.launch {
settingsStore.settings.collect { s -> // Only react when httpPort or apiKey actually change
if (wsClient.wsUrl != s.wsUrl || wsClient.apiKey != s.apiKey) { 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.disconnect()
wsClient.wsUrl = s.wsUrl wsClient.wsUrl = wsUrl
wsClient.apiKey = s.apiKey
wsClient.connect() 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"
}
} }

View file

@ -78,11 +78,7 @@ val appModule = module {
val clientId = prefs.getString("client_id", null) ?: UUID.randomUUID().toString().also { val clientId = prefs.getString("client_id", null) ?: UUID.randomUUID().toString().also {
prefs.edit().putString("client_id", it).apply() prefs.edit().putString("client_id", it).apply()
} }
val gwSettings = get<GatewaySettingsStore>().settings.value WebSocketClient(get(), get(), clientId)
WebSocketClient(get(), get(), clientId).apply {
wsUrl = gwSettings.wsUrl
apiKey = gwSettings.apiKey
}
} }
// ImageFileStorage // ImageFileStorage
@ -134,5 +130,5 @@ val appModule = module {
// ViewModel // ViewModel
viewModel { ChatViewModel(get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { ChatViewModel(get(), get(), get(), get(), get(), get(), get(), get()) }
viewModel { SettingsViewModel(get(), get(), get()) } viewModel { SettingsViewModel(get(), get(), get()) }
viewModel { AppSettingsViewModel(get()) } viewModel { AppSettingsViewModel(get(), get()) }
} }

View file

@ -59,7 +59,7 @@ fun AppSettingsScreen(
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
var apiKeyHidden by remember { mutableStateOf(true) } var apiKeyHidden by remember { mutableStateOf(true) }
val saveEnabled by remember { derivedStateOf { !uiState.hasErrors } } val saveEnabled by remember { derivedStateOf { !uiState.hasErrors && !uiState.saving } }
Box( Box(
modifier = Modifier modifier = Modifier
@ -92,7 +92,7 @@ fun AppSettingsScreen(
containerColor = Color.Transparent, containerColor = Color.Transparent,
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("App Settings") }, title = { Text("Connection") },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
@ -115,7 +115,7 @@ fun AppSettingsScreen(
), ),
modifier = Modifier.padding(end = 8.dp), modifier = Modifier.padding(end = 8.dp),
) { ) {
Text("Save") Text(if (uiState.saving) "Saving…" else "Save")
} }
}, },
) )
@ -135,10 +135,23 @@ fun AppSettingsScreen(
color = NeonCyan, 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( OutlinedTextField(
value = uiState.apiKey, value = uiState.apiKey,
onValueChange = { viewModel.onApiKeyChange(it) }, onValueChange = { viewModel.onApiKeyChange(it) },
label = { Text("Gateway API Key", color = TextSecondary) }, label = { Text("API Key", color = TextSecondary) },
singleLine = true, singleLine = true,
visualTransformation = if (apiKeyHidden) PasswordVisualTransformation() else VisualTransformation.None, visualTransformation = if (apiKeyHidden) PasswordVisualTransformation() else VisualTransformation.None,
trailingIcon = { trailingIcon = {
@ -154,34 +167,16 @@ fun AppSettingsScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
OutlinedTextField( uiState.error?.let { error ->
value = uiState.wsPort, Text(
onValueChange = { viewModel.onWsPortChange(it) }, error,
label = { Text("Gateway WS Port", color = TextSecondary) }, style = MaterialTheme.typography.bodySmall,
placeholder = { Text("18793", color = TextSecondary.copy(alpha = 0.5f)) }, color = MaterialTheme.colorScheme.error,
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(),
) )
} }
} }
} }
}
} }
@Composable @Composable

View file

@ -4,20 +4,23 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.clawdroid.backend.api.GatewaySettings import io.clawdroid.backend.api.GatewaySettings
import io.clawdroid.backend.api.GatewaySettingsStore import io.clawdroid.backend.api.GatewaySettingsStore
import io.clawdroid.backend.config.ConfigApiClient
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
data class AppSettingsUiState( data class AppSettingsUiState(
val apiKey: String = "", val apiKey: String = "",
val wsPort: String = "18793",
val httpPort: String = "18790", 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 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? { private fun portError(value: String): String? {
@ -28,6 +31,7 @@ private fun portError(value: String): String? {
class AppSettingsViewModel( class AppSettingsViewModel(
private val settingsStore: GatewaySettingsStore, private val settingsStore: GatewaySettingsStore,
private val configApiClient: ConfigApiClient,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(AppSettingsUiState()) private val _uiState = MutableStateFlow(AppSettingsUiState())
@ -37,43 +41,48 @@ class AppSettingsViewModel(
val current = settingsStore.settings.value val current = settingsStore.settings.value
_uiState.value = AppSettingsUiState( _uiState.value = AppSettingsUiState(
apiKey = current.apiKey, apiKey = current.apiKey,
wsPort = current.wsPort.toString(),
httpPort = current.httpPort.toString(), httpPort = current.httpPort.toString(),
) )
} }
fun onApiKeyChange(value: String) { fun onApiKeyChange(value: String) {
_uiState.update { it.copy(apiKey = value) } _uiState.update { it.copy(apiKey = value, error = null) }
}
fun onWsPortChange(value: String) {
if (value.isEmpty() || value.toIntOrNull() != null) {
_uiState.update { it.copy(wsPort = value) }
}
} }
fun onHttpPortChange(value: String) { fun onHttpPortChange(value: String) {
if (value.isEmpty() || value.toIntOrNull() != null) { if (value.isEmpty() || value.toIntOrNull() != null) {
_uiState.update { it.copy(httpPort = value) } _uiState.update { it.copy(httpPort = value, error = null) }
} }
} }
fun save(onComplete: () -> Unit) { fun save(onComplete: () -> Unit) {
viewModelScope.launch { viewModelScope.launch {
val state = _uiState.value 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() val defaults = GatewaySettings()
fun validPort(raw: String, fallback: Int): Int { val newPort = state.httpPort.toIntOrNull()?.takeIf { it in 1..65535 } ?: defaults.httpPort
val port = raw.toIntOrNull() ?: return fallback val newKey = state.apiKey
return if (port in 1..65535) port else fallback
// 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))
})
} }
val settings = GatewaySettings(
wsPort = validPort(state.wsPort, defaults.wsPort), try {
httpPort = validPort(state.httpPort, defaults.httpPort), configApiClient.saveConfig(payload)
apiKey = state.apiKey, // Persist new values locally after remote success
) settingsStore.update(GatewaySettings(httpPort = newPort, apiKey = newKey))
settingsStore.update(settings) _uiState.update { it.copy(saving = false) }
onComplete() onComplete()
} catch (e: Exception) {
_uiState.update { it.copy(saving = false, error = e.message ?: "Save failed") }
}
} }
} }
} }

View file

@ -21,7 +21,6 @@ class GatewaySettingsStoreImpl(
) : GatewaySettingsStore { ) : GatewaySettingsStore {
private object Keys { private object Keys {
val WS_PORT = intPreferencesKey("ws_port")
val HTTP_PORT = intPreferencesKey("http_port") val HTTP_PORT = intPreferencesKey("http_port")
val API_KEY = stringPreferencesKey("api_key") val API_KEY = stringPreferencesKey("api_key")
} }
@ -29,7 +28,6 @@ class GatewaySettingsStoreImpl(
override val settings: StateFlow<GatewaySettings> = override val settings: StateFlow<GatewaySettings> =
context.gatewayDataStore.data.map { prefs -> context.gatewayDataStore.data.map { prefs ->
GatewaySettings( GatewaySettings(
wsPort = prefs[Keys.WS_PORT] ?: DEFAULT.wsPort,
httpPort = prefs[Keys.HTTP_PORT] ?: DEFAULT.httpPort, httpPort = prefs[Keys.HTTP_PORT] ?: DEFAULT.httpPort,
apiKey = prefs[Keys.API_KEY] ?: DEFAULT.apiKey, apiKey = prefs[Keys.API_KEY] ?: DEFAULT.apiKey,
) )
@ -41,7 +39,6 @@ class GatewaySettingsStoreImpl(
override suspend fun update(settings: GatewaySettings) { override suspend fun update(settings: GatewaySettings) {
context.gatewayDataStore.edit { prefs -> context.gatewayDataStore.edit { prefs ->
prefs[Keys.WS_PORT] = settings.wsPort
prefs[Keys.HTTP_PORT] = settings.httpPort prefs[Keys.HTTP_PORT] = settings.httpPort
prefs[Keys.API_KEY] = settings.apiKey prefs[Keys.API_KEY] = settings.apiKey
} }

View file

@ -1,10 +1,8 @@
package io.clawdroid.backend.api package io.clawdroid.backend.api
data class GatewaySettings( data class GatewaySettings(
val wsPort: Int = 18793,
val httpPort: Int = 18790, val httpPort: Int = 18790,
val apiKey: String = "", val apiKey: String = "",
) { ) {
val wsUrl: String get() = "ws://127.0.0.1:$wsPort/ws"
val httpBaseUrl: String get() = "http://127.0.0.1:$httpPort" val httpBaseUrl: String get() = "http://127.0.0.1:$httpPort"
} }

View file

@ -22,7 +22,7 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.net.URLEncoder
class WebSocketClient( class WebSocketClient(
private val client: HttpClient, private val client: HttpClient,
@ -42,7 +42,6 @@ class WebSocketClient(
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
@Volatile var wsUrl: String = "ws://127.0.0.1:18793/ws" @Volatile var wsUrl: String = "ws://127.0.0.1:18793/ws"
@Volatile var apiKey: String = ""
fun connect() { fun connect() {
if (connectJob?.isActive == true) return if (connectJob?.isActive == true) return
@ -52,11 +51,8 @@ class WebSocketClient(
try { try {
_connectionState.value = ConnectionState.CONNECTING _connectionState.value = ConnectionState.CONNECTING
val currentWsUrl = wsUrl val currentWsUrl = wsUrl
val currentApiKey = apiKey val separator = if ('?' in currentWsUrl) '&' else '?'
val url = buildString { val url = "${currentWsUrl}${separator}client_id=$clientId&client_type=$clientType"
append("$currentWsUrl?client_id=$clientId&client_type=$clientType")
if (currentApiKey.isNotEmpty()) append("&api_key=${URLEncoder.encode(currentApiKey, "UTF-8")}")
}
client.webSocket(url) { client.webSocket(url) {
session = this session = this
_connectionState.value = ConnectionState.CONNECTED _connectionState.value = ConnectionState.CONNECTED

View file

@ -492,7 +492,7 @@ func gatewayCmd() {
fmt.Printf("Error starting gateway HTTP server: %v\n", err) fmt.Printf("Error starting gateway HTTP server: %v\n", err)
os.Exit(1) 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 // Setup cron tool and service
cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.DataPath(), cfg.Agents.Defaults.RestrictToWorkspace, cfg.Tools.Exec.Enabled) 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.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") fmt.Println("Press Ctrl+C to stop")
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())

View file

@ -2,6 +2,7 @@ package channels
import ( import (
"context" "context"
"crypto/subtle"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "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) { 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) conn, err := c.upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
logger.ErrorCF("websocket", "Upgrade failed", map[string]interface{}{ logger.ErrorCF("websocket", "Upgrade failed", map[string]interface{}{

View file

@ -124,6 +124,7 @@ type WebSocketConfig struct {
Host string `json:"host" label:"Host" env:"CLAWDROID_CHANNELS_WEBSOCKET_HOST"` Host string `json:"host" label:"Host" env:"CLAWDROID_CHANNELS_WEBSOCKET_HOST"`
Port int `json:"port" label:"Port" env:"CLAWDROID_CHANNELS_WEBSOCKET_PORT"` Port int `json:"port" label:"Port" env:"CLAWDROID_CHANNELS_WEBSOCKET_PORT"`
Path string `json:"path" label:"Path" env:"CLAWDROID_CHANNELS_WEBSOCKET_PATH"` 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"` AllowFrom FlexibleStringSlice `json:"allow_from" label:"Allow From" env:"CLAWDROID_CHANNELS_WEBSOCKET_ALLOW_FROM"`
} }
@ -138,7 +139,6 @@ type RateLimitsConfig struct {
} }
type GatewayConfig struct { type GatewayConfig struct {
Host string `json:"host" label:"Host" env:"CLAWDROID_GATEWAY_HOST"`
Port int `json:"port" label:"Port" env:"CLAWDROID_GATEWAY_PORT"` Port int `json:"port" label:"Port" env:"CLAWDROID_GATEWAY_PORT"`
APIKey string `json:"api_key" label:"API Key" env:"CLAWDROID_GATEWAY_API_KEY"` APIKey string `json:"api_key" label:"API Key" env:"CLAWDROID_GATEWAY_API_KEY"`
} }
@ -249,7 +249,6 @@ func DefaultConfig() *Config {
}, },
}, },
Gateway: GatewayConfig{ Gateway: GatewayConfig{
Host: "127.0.0.1",
Port: 18790, Port: 18790,
}, },
Tools: ToolsConfig{ Tools: ToolsConfig{

View file

@ -67,9 +67,6 @@ func TestDefaultConfig_Temperature(t *testing.T) {
func TestDefaultConfig_Gateway(t *testing.T) { func TestDefaultConfig_Gateway(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()
if cfg.Gateway.Host != "127.0.0.1" {
t.Error("Gateway host should have default value")
}
if cfg.Gateway.Port == 0 { if cfg.Gateway.Port == 0 {
t.Error("Gateway port should have default value") t.Error("Gateway port should have default value")
} }
@ -207,9 +204,6 @@ func TestConfig_Complete(t *testing.T) {
if cfg.Agents.Defaults.MaxToolIterations == 0 { if cfg.Agents.Defaults.MaxToolIterations == 0 {
t.Error("MaxToolIterations should not be zero") 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 { if cfg.Gateway.Port == 0 {
t.Error("Gateway port should have default value") t.Error("Gateway port should have default value")
} }

View file

@ -65,6 +65,11 @@ func BuildSchema(defaultCfg *config.Config) SchemaResponse {
continue continue
} }
// Gateway is managed via the Connection screen, not the Backend Config UI.
if jsonTag == "gateway" {
continue
}
section := SchemaSection{ section := SchemaSection{
Key: jsonTag, Key: jsonTag,
Label: labelTag(field), Label: labelTag(field),

View file

@ -33,7 +33,7 @@ func (s *Server) Start() error {
mux.HandleFunc("GET /api/config", s.authMiddleware(s.handleGetConfig)) mux.HandleFunc("GET /api/config", s.authMiddleware(s.handleGetConfig))
mux.HandleFunc("PUT /api/config", s.authMiddleware(s.handlePutConfig)) 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{ s.server = &http.Server{
Addr: addr, Addr: addr,
Handler: mux, Handler: mux,

View file

@ -57,7 +57,6 @@ func TestServerStop_NoServer_NoError(t *testing.T) {
func TestServerStart_RegistersRoutesAndAuth(t *testing.T) { func TestServerStart_RegistersRoutesAndAuth(t *testing.T) {
cfg := config.DefaultConfig() 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.Port = -1 // invalid port prevents real listen but Start still builds handler
cfg.Gateway.APIKey = "test-key" cfg.Gateway.APIKey = "test-key"
@ -106,9 +105,9 @@ func TestServerStart_RegistersRoutesAndAuth(t *testing.T) {
func TestBuildSchema_SectionCount(t *testing.T) { func TestBuildSchema_SectionCount(t *testing.T) {
schema := BuildSchema(config.DefaultConfig()) schema := BuildSchema(config.DefaultConfig())
// Config has exported fields: LLM, Agents, Channels, Gateway, Tools, Heartbeat, RateLimits // Config has exported fields: LLM, Agents, Channels, Tools, Heartbeat, RateLimits (gateway excluded)
if len(schema.Sections) < 7 { if len(schema.Sections) < 6 {
t.Errorf("expected at least 7 sections, got %d", len(schema.Sections)) 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.max_tokens", float64(8192)}, // JSON numbers → float64
{"agents", "defaults.context_window", float64(128000)}, {"agents", "defaults.context_window", float64(128000)},
{"agents", "defaults.restrict_to_workspace", true}, {"agents", "defaults.restrict_to_workspace", true},
{"gateway", "host", "127.0.0.1"},
{"gateway", "port", float64(18790)},
{"heartbeat", "enabled", true}, {"heartbeat", "enabled", true},
{"heartbeat", "interval", float64(30)}, {"heartbeat", "interval", float64(30)},
} }
@ -387,8 +384,8 @@ func TestHandleGetSchema_Response(t *testing.T) {
t.Fatalf("failed to decode response: %v", err) t.Fatalf("failed to decode response: %v", err)
} }
if len(schema.Sections) < 7 { if len(schema.Sections) < 6 {
t.Errorf("expected at least 7 sections, got %d", len(schema.Sections)) t.Errorf("expected at least 6 sections, got %d", len(schema.Sections))
} }
// Verify each section has at least one field // Verify each section has at least one field
@ -451,7 +448,6 @@ func TestHandleGetConfig_NonSecretValues(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
cfg.LLM.Model = "test-model" cfg.LLM.Model = "test-model"
cfg.LLM.BaseURL = "https://example.com" cfg.LLM.BaseURL = "https://example.com"
cfg.Gateway.Host = "0.0.0.0"
cfg.Gateway.Port = 9999 cfg.Gateway.Port = 9999
s := newTestServer(cfg) s := newTestServer(cfg)
@ -475,9 +471,6 @@ func TestHandleGetConfig_NonSecretValues(t *testing.T) {
} }
gw := result["gateway"].(map[string]interface{}) 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) { if gw["port"] != float64(9999) {
t.Errorf("gateway port = %v, want %v", gw["port"], 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) { func TestHandlePutConfig_PartialUpdatePreservesOtherSections(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
cfg.LLM.Model = "original-model" cfg.LLM.Model = "original-model"
cfg.Gateway.Host = "10.0.0.1"
cfg.Gateway.Port = 12345 cfg.Gateway.Port = 12345
cfg.Channels.Telegram.Enabled = true cfg.Channels.Telegram.Enabled = true
cfg.Heartbeat.Interval = 60 cfg.Heartbeat.Interval = 60
@ -751,9 +743,6 @@ func TestHandlePutConfig_PartialUpdatePreservesOtherSections(t *testing.T) {
if saved.LLM.Model != "new-model" { if saved.LLM.Model != "new-model" {
t.Errorf("LLM model = %q, want %q", 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 { if saved.Gateway.Port != 12345 {
t.Errorf("Gateway port = %d, want %d (should be preserved)", 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) { func TestServerRouting_PutConfig_AuthRequired(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
cfg.Gateway.Host = "127.0.0.1"
cfg.Gateway.Port = -1 cfg.Gateway.Port = -1
cfg.Gateway.APIKey = "route-test-key" cfg.Gateway.APIKey = "route-test-key"
@ -2064,7 +2052,6 @@ func TestHandlePutConfig_ThenGetRoundTrip(t *testing.T) {
func TestServerStop_CalledTwice(t *testing.T) { func TestServerStop_CalledTwice(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
cfg.Gateway.Host = "127.0.0.1"
cfg.Gateway.Port = -1 cfg.Gateway.Port = -1
s := NewServer(cfg, "/tmp/config.json", nil) s := NewServer(cfg, "/tmp/config.json", nil)
@ -2085,7 +2072,6 @@ func TestServerStop_CalledTwice(t *testing.T) {
func TestServerRouting_UnsupportedMethods(t *testing.T) { func TestServerRouting_UnsupportedMethods(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
cfg.Gateway.Host = "127.0.0.1"
cfg.Gateway.Port = -1 cfg.Gateway.Port = -1
s := NewServer(cfg, "/tmp/config.json", nil) s := NewServer(cfg, "/tmp/config.json", nil)
@ -2107,7 +2093,6 @@ func TestServerRouting_UnsupportedMethods(t *testing.T) {
func TestServerRouting_UnknownPath(t *testing.T) { func TestServerRouting_UnknownPath(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
cfg.Gateway.Host = "127.0.0.1"
cfg.Gateway.Port = -1 cfg.Gateway.Port = -1
s := NewServer(cfg, "/tmp/config.json", nil) s := NewServer(cfg, "/tmp/config.json", nil)
@ -2168,7 +2153,6 @@ func TestAuthMiddleware_TokenWithWhitespace_403(t *testing.T) {
func TestServerRouting_SchemaAuthRequired(t *testing.T) { func TestServerRouting_SchemaAuthRequired(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
cfg.Gateway.Host = "127.0.0.1"
cfg.Gateway.Port = -1 cfg.Gateway.Port = -1
cfg.Gateway.APIKey = "schema-auth-key" 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()) schema := BuildSchema(config.DefaultConfig())
var gw *SchemaSection for _, sec := range schema.Sections {
for i := range schema.Sections { if sec.Key == "gateway" {
if schema.Sections[i].Key == "gateway" { t.Error("gateway section should be excluded from schema")
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")
} }
} }
} }