Provider-Wechsel nur für Admin (403 für Nutzer); App blendet Provider-Auswahl für Nicht-Admins aus; v0.3.1

This commit is contained in:
OpenClaw Dev 2026-11-01 04:17:53 +01:00
parent 161bc02bb9
commit 70d316851a
12 changed files with 102 additions and 17 deletions

View file

@ -0,0 +1,4 @@
kotlin version: 2.0.21
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
1. Kotlin compile daemon is ready

View file

@ -31,8 +31,8 @@ android {
applicationId = "dev.byteclaw.client" applicationId = "dev.byteclaw.client"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 3 versionCode = 4
versionName = "0.3.0" versionName = "0.3.1"
} }
signingConfigs { signingConfigs {

View file

@ -44,6 +44,8 @@ data class UiState(
/** Login screen state. */ /** Login screen state. */
val authBusy: Boolean = false, val authBusy: Boolean = false,
val authError: String? = null, val authError: String? = null,
/** From /api/auth/me: only the admin may change providers. */
val isAdmin: Boolean = false,
) { ) {
/** What the current chat effectively uses: session override, else the global active provider. */ /** What the current chat effectively uses: session override, else the global active provider. */
val effectiveProvider: ActiveProvider? get() = sessionProvider ?: provider.active val effectiveProvider: ActiveProvider? get() = sessionProvider ?: provider.active
@ -266,6 +268,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
} }
fun loadProviders() = viewModelScope.launch { fun loadProviders() = viewModelScope.launch {
runCatching { api().me() }.onSuccess { u -> _state.update { it.copy(isAdmin = u.admin) } }
runCatching { api().providers() } runCatching { api().providers() }
.onSuccess { r -> _state.update { it.copy(provider = it.provider.copy(providers = r.providers, active = r.active, status = null)) } } .onSuccess { r -> _state.update { it.copy(provider = it.provider.copy(providers = r.providers, active = r.active, status = null)) } }
.onFailure { e -> if (e is UnauthorizedException) fail(e) else _state.update { it.copy(provider = it.provider.copy(status = "Provider laden fehlgeschlagen: ${e.message}")) } } .onFailure { e -> if (e is UnauthorizedException) fail(e) else _state.update { it.copy(provider = it.provider.copy(status = "Provider laden fehlgeschlagen: ${e.message}")) } }

View file

@ -296,7 +296,14 @@ fun SettingsScreen(
} }
status?.let { item { Text(it) } } status?.let { item { Text(it) } }
if (state.settings.isConfigured) { if (state.settings.isConfigured) {
item { ProviderSection(state.provider, onLoadProviders, onLoadModels, onSelectProvider) } if (state.isAdmin) {
item { ProviderSection(state.provider, onLoadProviders, onLoadModels, onSelectProvider) }
} else {
item {
Text("LLM-Provider: " + (state.provider.active?.let { ProviderPresets.label(it.provider) + " · " + it.model } ?: "–") +
"\nNur der Server-Admin kann den Provider ändern.", style = MaterialTheme.typography.bodySmall)
}
}
} }
} }
} }
@ -370,7 +377,7 @@ private fun SessionProviderChip(state: UiState, onPick: (String) -> Unit, initia
Box { Box {
AssistChip( AssistChip(
onClick = { open = true }, label = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelSmall) }, onClick = { open = true }, label = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelSmall) },
enabled = state.settings.isConfigured && !state.busy, enabled = state.settings.isConfigured && !state.busy && state.isAdmin,
modifier = Modifier.height(28.dp).widthIn(max = 230.dp), modifier = Modifier.height(28.dp).widthIn(max = 230.dp),
) )
DropdownMenu(expanded = open, onDismissRequest = { open = false }) { DropdownMenu(expanded = open, onDismissRequest = { open = false }) {

View file

@ -43,7 +43,7 @@ class PreviewScreenshots(private val dark: Boolean) {
@Test fun chat() = snap("chat") { @Test fun chat() = snap("chat") {
ChatScreen( ChatScreen(
state = UiState( state = UiState(isAdmin = true,
settings = settings, sessionId = "demo", busy = true, settings = settings, sessionId = "demo", busy = true,
lines = listOf( lines = listOf(
ChatLine("user", "[DEMO] Wie spät ist es in Berlin, und was steht auf golang.org?"), ChatLine("user", "[DEMO] Wie spät ist es in Berlin, und was steht auf golang.org?"),
@ -68,7 +68,7 @@ class PreviewScreenshots(private val dark: Boolean) {
@Test fun chatProvider() = snap("chat_provider") { @Test fun chatProvider() = snap("chat_provider") {
ChatScreen( ChatScreen(
state = UiState( state = UiState(isAdmin = true,
settings = settings, sessionId = "demo", provider = demoProviders, settings = settings, sessionId = "demo", provider = demoProviders,
sessionProvider = ActiveProvider("gonkarouter", "MiniMaxAI/MiniMax-M2.7"), sessionProvider = ActiveProvider("gonkarouter", "MiniMaxAI/MiniMax-M2.7"),
lines = listOf( lines = listOf(
@ -83,7 +83,7 @@ class PreviewScreenshots(private val dark: Boolean) {
@Test fun chatMarkdown() = snap("chat_markdown") { @Test fun chatMarkdown() = snap("chat_markdown") {
ChatScreen( ChatScreen(
state = UiState( state = UiState(isAdmin = true,
settings = settings, sessionId = "demo", online = true, settings = settings, sessionId = "demo", online = true,
lines = listOf( lines = listOf(
ChatLine("user", "[DEMO] Wie lese ich in Go eine Datei zeilenweise?"), ChatLine("user", "[DEMO] Wie lese ich in Go eine Datei zeilenweise?"),
@ -96,7 +96,7 @@ class PreviewScreenshots(private val dark: Boolean) {
@Test fun sessionsSearch() = snap("sessions_search") { @Test fun sessionsSearch() = snap("sessions_search") {
SessionsScreen( SessionsScreen(
state = UiState( state = UiState(isAdmin = true,
settings = settings, settings = settings,
sessions = listOf( sessions = listOf(
Session("1", "[DEMO] Go-Datei zeilenweise lesen", "2026-10-25T03:32:00Z"), Session("1", "[DEMO] Go-Datei zeilenweise lesen", "2026-10-25T03:32:00Z"),
@ -110,7 +110,7 @@ class PreviewScreenshots(private val dark: Boolean) {
@Test fun sessions() = snap("sessions") { @Test fun sessions() = snap("sessions") {
SessionsScreen( SessionsScreen(
state = UiState( state = UiState(isAdmin = true,
settings = settings, settings = settings,
sessions = listOf( sessions = listOf(
Session("1", "[DEMO] Wie spät ist es in Berlin?", "2026-10-25T03:32:00Z"), Session("1", "[DEMO] Wie spät ist es in Berlin?", "2026-10-25T03:32:00Z"),
@ -126,14 +126,14 @@ class PreviewScreenshots(private val dark: Boolean) {
@Test fun settings() = snap("settings") { @Test fun settings() = snap("settings") {
SettingsScreen( SettingsScreen(
state = UiState(settings = settings), onSave = {}, onTest = { _, _ -> }, onBack = {}, state = UiState(isAdmin = true, settings = settings), onSave = {}, onTest = { _, _ -> }, onBack = {},
initialStatus = "Verbindung OK · Provider: Dahl (dahl.global) · Modell: MiniMaxAI/MiniMax-M2.7 [DEMO]", initialStatus = "Verbindung OK · Provider: Dahl (dahl.global) · Modell: MiniMaxAI/MiniMax-M2.7 [DEMO]",
) )
} }
@Test fun providers() = snap("providers") { @Test fun providers() = snap("providers") {
SettingsScreen( SettingsScreen(
state = UiState( state = UiState(isAdmin = true,
settings = settings, settings = settings,
provider = ProviderUi( provider = ProviderUi(
active = ActiveProvider("dahl", "MiniMaxAI/MiniMax-M2.7"), active = ActiveProvider("dahl", "MiniMaxAI/MiniMax-M2.7"),
@ -152,12 +152,12 @@ class PreviewScreenshots(private val dark: Boolean) {
} }
@Test fun login() = snap("login") { @Test fun login() = snap("login") {
LoginScreen(state = UiState(settings = ServerSettings("https://demo.byteclaw.example", "", "crypto")), onAuthenticate = { _, _, _, _ -> }) LoginScreen(state = UiState(isAdmin = true, settings = ServerSettings("https://demo.byteclaw.example", "", "crypto")), onAuthenticate = { _, _, _, _ -> })
} }
@Test fun loginRegister() = snap("login_register") { @Test fun loginRegister() = snap("login_register") {
LoginScreen( LoginScreen(
state = UiState(settings = ServerSettings("https://demo.byteclaw.example"), authError = "Benutzername ist bereits vergeben"), state = UiState(isAdmin = true, settings = ServerSettings("https://demo.byteclaw.example"), authError = "Benutzername ist bereits vergeben"),
onAuthenticate = { _, _, _, _ -> }, initialRegister = true, initialUser = "crypto", onAuthenticate = { _, _, _, _ -> }, initialRegister = true, initialUser = "crypto",
) )
} }

View file

@ -0,0 +1,43 @@
package server
import (
"encoding/json"
"testing"
)
func TestProviderChangesAdminOnly(t *testing.T) {
ts, _ := setupFull(t, echo{})
ut := registerUser(t, ts.URL, "alice", "correct-horse-1")
uh := bearerHdr(ut)
if r := do(t, "GET", ts.URL+"/api/providers", "", uh); r.StatusCode != 200 {
t.Fatal("users may list providers", r.StatusCode)
}
if r := do(t, "PUT", ts.URL+"/api/providers/active", `{"provider":"dahl"}`, uh); r.StatusCode != 403 {
t.Fatal("user must not change global provider", r.StatusCode)
}
var me map[string]any
json.NewDecoder(do(t, "GET", ts.URL+"/api/auth/me", "", uh).Body).Decode(&me)
if me["admin"] != false {
t.Fatalf("%v", me)
}
// own session: chat ok, provider change forbidden
r := do(t, "POST", ts.URL+"/api/chat", `{"text":"hi"}`, uh)
if r.StatusCode != 200 {
t.Fatal("user chat", r.StatusCode)
}
var cr struct {
SessionID string `json:"session_id"`
}
json.NewDecoder(r.Body).Decode(&cr)
if cr.SessionID != "" {
if r := do(t, "PUT", ts.URL+"/api/sessions/"+cr.SessionID+"/provider", `{"provider":"openai"}`, uh); r.StatusCode != 403 {
t.Fatal("session provider", r.StatusCode)
}
}
if r := do(t, "POST", ts.URL+"/api/chat", `{"text":"hi","provider":"dahl"}`, uh); r.StatusCode != 403 {
t.Fatal("chat with provider", r.StatusCode)
}
if r := do(t, "PUT", ts.URL+"/api/providers/active", `{"provider":"dahl"}`, authH); r.StatusCode != 200 {
t.Fatal("admin allowed", r.StatusCode)
}
}

View file

@ -48,6 +48,9 @@ func (s *Server) listProviders(w http.ResponseWriter, _ *http.Request) {
} }
func (s *Server) setActiveProvider(w http.ResponseWriter, r *http.Request) { func (s *Server) setActiveProvider(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
if !s.needProviders(w) { if !s.needProviders(w) {
return return
} }
@ -82,6 +85,9 @@ func (s *Server) listModels(w http.ResponseWriter, r *http.Request) {
// PUT /api/sessions/{id}/provider: per-session override; empty provider clears it. // PUT /api/sessions/{id}/provider: per-session override; empty provider clears it.
func (s *Server) setSessionProvider(w http.ResponseWriter, r *http.Request) { func (s *Server) setSessionProvider(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
if !s.needProviders(w) { if !s.needProviders(w) {
return return
} }
@ -148,3 +154,15 @@ func (s *Server) getSummary(w http.ResponseWriter, r *http.Request) {
} }
writeJSON(w, 200, sm) writeJSON(w, 200, sm)
} }
// errAdminOnly is returned when a non-admin tries to change provider settings.
var errAdminOnly = errors.New("admin only: provider settings can only be changed by the server admin")
// requireAdmin writes 403 unless the caller is the admin (static auth_token).
func requireAdmin(w http.ResponseWriter, r *http.Request) bool {
if principal(r.Context()).Admin {
return true
}
writeErr(w, http.StatusForbidden, errAdminOnly.Error())
return false
}

View file

@ -50,7 +50,7 @@ func setupFull(t *testing.T, p llm.Provider) (*httptest.Server, *store.Store) {
pr, _, err := pm.ForSession(ctx, sid) pr, _, err := pm.ForSession(ctx, sid)
return pr, err return pr, err
} }
s := &Server{Store: st, Token: tok, Log: slog.New(slog.NewTextHandler(io.Discard, nil)), RunTimeout: 5 * time.Second, Agent: ag, Providers: pm} s := &Server{Store: st, Token: tok, AllowRegistration: true, Log: slog.New(slog.NewTextHandler(io.Discard, nil)), RunTimeout: 5 * time.Second, Agent: ag, Providers: pm}
ts := httptest.NewServer(s.Handler()) ts := httptest.NewServer(s.Handler())
t.Cleanup(ts.Close) t.Cleanup(ts.Close)
return ts, st return ts, st

View file

@ -253,6 +253,9 @@ type chatReq struct {
func (s *Server) startSession(ctx context.Context, req chatReq) (string, error) { func (s *Server) startSession(ctx context.Context, req chatReq) (string, error) {
var a providers.Active var a providers.Active
if req.SessionID == "" && req.Provider != "" && s.Providers != nil { if req.SessionID == "" && req.Provider != "" && s.Providers != nil {
if !principal(ctx).Admin {
return "", errAdminOnly
}
var err error var err error
if a, err = s.Providers.Validate(req.Provider, req.Model); err != nil { if a, err = s.Providers.Validate(req.Provider, req.Model); err != nil {
return "", err return "", err
@ -288,6 +291,10 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
return return
} }
id, err := s.startSession(r.Context(), req) id, err := s.startSession(r.Context(), req)
if errors.Is(err, errAdminOnly) {
writeErr(w, http.StatusForbidden, err.Error())
return
}
if err != nil { if err != nil {
if req.SessionID == "" && req.Provider != "" { if req.SessionID == "" && req.Provider != "" {
providerErr(w, err) providerErr(w, err)

3
site/.netlify/state.json Normal file
View file

@ -0,0 +1,3 @@
{
"siteId": "eda745c3-d798-4dab-907e-aac0a7d94433"
}

View file

@ -1,3 +1,3 @@
/downloads/*.apk /downloads/*.apk
Content-Type: application/vnd.android.package-archive Content-Type: application/vnd.android.package-archive
Content-Disposition: attachment; filename="byteclaw-0.3.0.apk" Content-Disposition: attachment; filename="byteclaw-0.3.1.apk"

View file

@ -32,7 +32,7 @@
<p class="claim">Selbst gehosteter, schlanker KI-Assistent – <span>Go-Backend</span> + <span>Android-App</span>.</p> <p class="claim">Selbst gehosteter, schlanker KI-Assistent – <span>Go-Backend</span> + <span>Android-App</span>.</p>
<p class="lead">Ein einzelnes Go-Binary mit SQLite spricht mit jedem OpenAI-kompatiblen Anbieter, nutzt Tools und streamt Antworten live in die native Android-App. Deine Schlüssel bleiben auf deinem Server.</p> <p class="lead">Ein einzelnes Go-Binary mit SQLite spricht mit jedem OpenAI-kompatiblen Anbieter, nutzt Tools und streamt Antworten live in die native Android-App. Deine Schlüssel bleiben auf deinem Server.</p>
<div class="cta"> <div class="cta">
<a class="btn btn-primary" href="https://git.85.190.126.179.sslip.io/crypto/byteclaw/releases/download/v0.3.0/byteclaw-0.3.0-release.apk">Android-App laden · v0.3.0 (1,9 MB)</a> <a class="btn btn-primary" href="https://git.85.190.126.179.sslip.io/crypto/byteclaw/releases/download/v0.3.1/byteclaw-0.3.1-release.apk">Android-App laden · v0.3.1 (1,9 MB)</a>
<a class="btn btn-ghost" href="#quickstart">Quickstart ansehen</a> <a class="btn btn-ghost" href="#quickstart">Quickstart ansehen</a>
</div> </div>
</div> </div>
@ -89,7 +89,7 @@ curl -H "Authorization: Bearer $BYTECLAW_AUTH_TOKEN" \
<footer class="footer"> <footer class="footer">
<p><img src="assets/icon.svg" alt="" width="20" height="20"> ByteClaw · selbst gehostet, Go + Android</p> <p><img src="assets/icon.svg" alt="" width="20" height="20"> ByteClaw · selbst gehostet, Go + Android</p>
<p class="muted">Screenshots zeigen Demo-Daten. APK v0.3.0 · Android 8+ · signiert.</p> <p class="muted">Screenshots zeigen Demo-Daten. APK v0.3.1 · Android 8+ · signiert.</p>
</footer> </footer>
<script> <script>
document.querySelectorAll('[data-copy]').forEach(b=>b.addEventListener('click',()=>{ document.querySelectorAll('[data-copy]').forEach(b=>b.addEventListener('click',()=>{