Admin-gated Settings: runtime model selection, is_admin enforced (#90)
Build image / build-and-push (push) Successful in 11s
Build image / build-and-push (push) Successful in 11s
Closes #79. Agent model + on/off move into an admin-only Settings section, and is_admin is enforced for the first time. The live Runner is hot-swapped behind an atomic.Pointer with routes always registered, so a settings change takes effect with no restart; /capabilities reads the pointer. Secrets stay in the env, never the DB. Precedence: Settings → env → default. Verified live: swap on/off/model, bad spec → 400, race-clean. Gadfly blocking round addressed (dead code removed, error-swallowing fixed, frontend 503 handling, holder dedup).
This commit was merged in pull request #90.
This commit is contained in:
+11
-1
@@ -56,6 +56,16 @@ type stepEvent struct {
|
||||
}
|
||||
|
||||
func (h *handlers) agentChat(c *gin.Context) {
|
||||
// The route is always registered, so the assistant being off is a runtime
|
||||
// state, not a missing route: answer it plainly rather than 404ing a path
|
||||
// that exists. Loaded once here so a settings-driven swap mid-request can't
|
||||
// make it flip between the guard and the Run call.
|
||||
runner := h.agent.get()
|
||||
if runner == nil {
|
||||
writeAPIError(c, http.StatusServiceUnavailable, "AGENT_DISABLED", "the garden assistant isn't enabled on this instance")
|
||||
return
|
||||
}
|
||||
|
||||
var req chatRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a gardenId and a message are required")
|
||||
@@ -78,7 +88,7 @@ func (h *handlers) agentChat(c *gin.Context) {
|
||||
stopBeat := stream.keepAlive(keepAliveInterval)
|
||||
defer stopBeat()
|
||||
|
||||
turn, err := h.agent.Run(c.Request.Context(), actor.ID, req.GardenID, req.Message,
|
||||
turn, err := runner.Run(c.Request.Context(), actor.ID, req.GardenID, req.Message,
|
||||
replayHistory(history),
|
||||
func(s mdagent.Step) {
|
||||
send(chatEvent{Step: &stepEvent{Index: s.Index, Tools: toolNames(s)}})
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agent"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||
)
|
||||
|
||||
// agentHolder owns the live assistant Runner and lets an admin swap it at
|
||||
// runtime when the model settings change (#79).
|
||||
//
|
||||
// The Runner used to be a plain handlers field set once at boot, with the chat
|
||||
// routes registered only when it existed. That made the assistant permanently
|
||||
// whatever the environment said at startup. Now the routes are always
|
||||
// registered and the Runner lives behind an atomic pointer, so a settings change
|
||||
// can turn the assistant on, off, or onto a different model without a restart —
|
||||
// and without a data race against in-flight requests reading the pointer.
|
||||
//
|
||||
// A nil pointer means "no assistant right now"; handlers nil-check get() rather
|
||||
// than assuming a Runner is present.
|
||||
type agentHolder struct {
|
||||
svc *service.Service
|
||||
|
||||
ptr atomic.Pointer[agent.Runner]
|
||||
// rebuildMu serializes rebuilds so two concurrent settings saves can't
|
||||
// interleave into a torn "resolve A, resolve B, store A, store B" swap. The
|
||||
// read path (get) stays lock-free on the atomic pointer.
|
||||
rebuildMu sync.Mutex
|
||||
}
|
||||
|
||||
// newAgentHolder builds the holder and resolves the initial Runner from whatever
|
||||
// the settings + environment currently say. A resolution failure is logged and
|
||||
// left as "no assistant", never fatal: a garden planner must still boot.
|
||||
func newAgentHolder(ctx context.Context, svc *service.Service) *agentHolder {
|
||||
h := &agentHolder{svc: svc}
|
||||
h.rebuild(ctx)
|
||||
return h
|
||||
}
|
||||
|
||||
// get returns the current Runner, or nil if the assistant is off.
|
||||
func (h *agentHolder) get() *agent.Runner { return h.ptr.Load() }
|
||||
|
||||
// rebuild resolves the effective agent configuration and swaps the Runner to
|
||||
// match: a new one when the assistant should be on, nil when it shouldn't. It is
|
||||
// safe to call at boot and from a settings save; concurrent calls serialize.
|
||||
//
|
||||
// It logs what it did rather than returning an error, because every caller wants
|
||||
// the same thing — best-effort apply, keep serving either way — and a settings
|
||||
// save must not fail just because the new model won't resolve. The save already
|
||||
// validated the spec; a rebuild failure here means the environment changed under
|
||||
// it, and "assistant off, with a reason in the log" is the right outcome.
|
||||
func (h *agentHolder) rebuild(ctx context.Context) {
|
||||
h.rebuildMu.Lock()
|
||||
defer h.rebuildMu.Unlock()
|
||||
|
||||
eff, err := h.svc.EffectiveAgent(ctx)
|
||||
if err != nil {
|
||||
// EffectiveAgent errors only if the settings row can't be read — a DB fault,
|
||||
// and a very transient one when it happens right after a settings write. We
|
||||
// keep the current Runner rather than tear down a working assistant on a
|
||||
// blip: the state is already persisted, so the next rebuild (any later save,
|
||||
// or a restart) reconciles it. Loud, because a persistent failure here means
|
||||
// the live assistant no longer matches stored settings.
|
||||
slog.Error("api: could not resolve agent settings; leaving the assistant as-is", "error", err)
|
||||
return
|
||||
}
|
||||
if !eff.Ready() {
|
||||
if h.ptr.Swap(nil) != nil {
|
||||
slog.Info("api: garden assistant turned off",
|
||||
"enabled", eff.Enabled, "hasKey", eff.APIKey != "", "hasModel", eff.Model != "")
|
||||
}
|
||||
return
|
||||
}
|
||||
runner, err := agent.NewRunner(h.svc, eff.APIKey, eff.Model)
|
||||
if err != nil {
|
||||
// Configured but unusable. Turn the assistant off rather than leaving a
|
||||
// stale Runner on the old model — an admin who just pointed it at a broken
|
||||
// spec should see it stop, not silently keep answering on the previous one.
|
||||
slog.Error("api: garden assistant disabled (model won't resolve)", "error", err, "model", eff.Model)
|
||||
h.ptr.Store(nil)
|
||||
return
|
||||
}
|
||||
h.ptr.Store(runner)
|
||||
slog.Info("api: garden assistant ready", "model", eff.Model)
|
||||
}
|
||||
@@ -6,24 +6,40 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAgentRoutesAbsentWithoutAKey — an instance with no API key must start,
|
||||
// serve the app, and simply not offer the assistant. The routes aren't
|
||||
// registered at all, so this is a 404 rather than a handler that apologizes:
|
||||
// the same shape as OIDC when unconfigured.
|
||||
func TestAgentRoutesAbsentWithoutAKey(t *testing.T) {
|
||||
// TestAgentDisabledWithoutAKey — an instance with no API key must start, serve
|
||||
// the app, and not offer the assistant.
|
||||
//
|
||||
// The contract CHANGED with #79: the chat route is now always registered (so a
|
||||
// settings change can turn the assistant on without a restart), so "off" is a
|
||||
// runtime 503 rather than a missing route. capabilities reports agent:false, and
|
||||
// the frontend keys the chat tab off that — so a user never reaches the 503.
|
||||
func TestAgentDisabledWithoutAKey(t *testing.T) {
|
||||
r := authEngine(t, localCfg()) // localCfg has no agent configuration
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
gid := createGardenAPI(t, r, cookie, "G")
|
||||
|
||||
// Chat is refused, plainly, because there is no Runner to run.
|
||||
w := doJSON(t, r, http.MethodPost, "/api/v1/agent/chat",
|
||||
map[string]any{"gardenId": gid, "message": "plant garlic"}, cookie)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("chat without a key: status %d, want 404", w.Code)
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("chat without a key: status %d, want 503", w.Code)
|
||||
}
|
||||
|
||||
// Capabilities advertises the assistant as unavailable, which is what the UI
|
||||
// actually consults.
|
||||
w = doJSON(t, r, http.MethodGet, "/api/v1/capabilities", nil, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("capabilities: status %d", w.Code)
|
||||
}
|
||||
if agent, _ := decodeMap(t, w.Body.Bytes())["agent"].(bool); agent {
|
||||
t.Error("capabilities reported agent:true with no key")
|
||||
}
|
||||
|
||||
// History is just stored data behind the ordinary garden-role check, so it
|
||||
// reads fine (empty) whether or not a Runner exists — it isn't gated on one.
|
||||
path := "/api/v1/gardens/" + strconv.FormatInt(gid, 10) + "/agent/history"
|
||||
if w := doJSON(t, r, http.MethodGet, path, nil, cookie); w.Code != http.StatusNotFound {
|
||||
t.Errorf("history without a key: status %d, want 404", w.Code)
|
||||
if w := doJSON(t, r, http.MethodGet, path, nil, cookie); w.Code != http.StatusOK {
|
||||
t.Errorf("history without a key: status %d, want 200 (it's data, not the model)", w.Code)
|
||||
}
|
||||
|
||||
// And the rest of the app is entirely unaffected.
|
||||
|
||||
+35
-38
@@ -6,13 +6,13 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
sloggin "github.com/samber/slog-gin"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agent"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||
)
|
||||
@@ -23,9 +23,12 @@ type handlers struct {
|
||||
cfg *config.Config
|
||||
svc *service.Service
|
||||
oidc *oidcClient // nil unless OIDC is configured (see config.OIDCReady)
|
||||
// agent is nil unless the assistant is configured; the chat routes are only
|
||||
// registered when it isn't, so a handler never has to check.
|
||||
agent *agent.Runner
|
||||
// agent holds the live Runner behind an atomic pointer. Unlike oidc it is
|
||||
// never nil — the holder is always present and its Runner may be nil when the
|
||||
// assistant is off. The chat routes are registered unconditionally and
|
||||
// nil-check agent.get(), so a settings change can turn the assistant on or off
|
||||
// at runtime (#79) rather than only at boot.
|
||||
agent *agentHolder
|
||||
}
|
||||
|
||||
// New builds the gin engine with the standard middleware stack and registers the
|
||||
@@ -131,37 +134,30 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
||||
plantings.PATCH("/:id", h.updatePlanting)
|
||||
plantings.DELETE("/:id", h.deletePlanting)
|
||||
|
||||
// The garden assistant, registered only when it can actually be offered —
|
||||
// the same shape as OIDC. An instance with no API key serves the app
|
||||
// normally and simply doesn't have these routes.
|
||||
if !cfg.Agent.Ready() {
|
||||
// Say WHY, at startup, in the logs an operator is already looking at.
|
||||
// Someone who set the key and sees no assistant otherwise has nothing to
|
||||
// check — and "is the variable reaching the container?" is exactly the
|
||||
// question they need answered.
|
||||
slog.Info("api: garden assistant disabled",
|
||||
"enabled", cfg.Agent.Enabled,
|
||||
"hasApiKey", cfg.Agent.OllamaCloudAPIKey != "",
|
||||
"model", cfg.Agent.Model,
|
||||
"hint", "needs OLLAMA_CLOUD_API_KEY set in the container's environment (not just the stack's)")
|
||||
}
|
||||
if cfg.Agent.Ready() {
|
||||
runner, err := agent.NewRunner(svc, cfg)
|
||||
if err != nil {
|
||||
// Configured but unusable (an unresolvable model spec, say). Log it and
|
||||
// carry on without the assistant rather than refusing to start: a
|
||||
// garden planner that won't boot because of a chat feature is worse
|
||||
// than one without chat.
|
||||
slog.Error("api: garden assistant disabled", "error", err)
|
||||
} else {
|
||||
h.agent = runner
|
||||
agentGroup := v1.Group("/agent", h.requireAuth())
|
||||
agentGroup.POST("/chat", h.agentChat)
|
||||
gardens.GET("/:id/agent/history", h.getAgentHistory)
|
||||
gardens.DELETE("/:id/agent/history", h.deleteAgentHistory)
|
||||
slog.Info("api: garden assistant enabled", "model", cfg.Agent.Model)
|
||||
}
|
||||
// The garden assistant. Its routes are registered UNCONDITIONALLY and the live
|
||||
// Runner sits behind an atomic pointer in the holder, so a settings change can
|
||||
// turn the assistant on or off at runtime (#79). Each handler nil-checks
|
||||
// agent.get(); a chat request while the assistant is off gets a clean 503
|
||||
// (AGENT_DISABLED), not a panic and not a missing route.
|
||||
//
|
||||
// The holder resolves its initial Runner from settings + environment at
|
||||
// construction. If the key never reaches the container, the assistant is off
|
||||
// and the reason is logged below — the same operability need #72 added.
|
||||
h.agent = newAgentHolder(context.Background(), svc)
|
||||
if cfg.Agent.OllamaCloudAPIKey == "" {
|
||||
slog.Info("api: garden assistant has no API key",
|
||||
"hint", "set OLLAMA_CLOUD_API_KEY in the container's environment (not just the stack's); the model can be chosen in Settings")
|
||||
}
|
||||
agentGroup := v1.Group("/agent", h.requireAuth())
|
||||
agentGroup.POST("/chat", h.agentChat)
|
||||
gardens.GET("/:id/agent/history", h.getAgentHistory)
|
||||
gardens.DELETE("/:id/agent/history", h.deleteAgentHistory)
|
||||
|
||||
// Instance settings: admin-only, and the first thing to enforce is_admin.
|
||||
// requireAdmin runs after requireAuth (it reads the actor requireAuth stored).
|
||||
settings := v1.Group("/settings", h.requireAuth(), h.requireAdmin())
|
||||
settings.GET("", h.getSettings)
|
||||
settings.PATCH("", h.updateSettings)
|
||||
|
||||
// Undo. A change set is addressed by its own id; the service resolves the
|
||||
// owning garden for the permission check, same as objects and plantings.
|
||||
@@ -203,11 +199,12 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
||||
// capabilities reports what this instance can actually do, so the UI offers only
|
||||
// what works.
|
||||
//
|
||||
// It reports whether the runner BUILT, not whether it was configured: a
|
||||
// configured-but-unresolvable model leaves the routes unregistered, and saying
|
||||
// "yes" there would offer a chat tab whose first message 404s.
|
||||
// It reports whether the assistant is live RIGHT NOW, not merely configured:
|
||||
// the chat routes always exist, but a request while the Runner is nil is refused,
|
||||
// so offering the tab must track the live Runner. Reading agent.get() (an atomic
|
||||
// load) means this reflects a settings-driven swap on the very next poll.
|
||||
func (h *handlers) capabilities(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"agent": h.agent != nil})
|
||||
c.JSON(http.StatusOK, gin.H{"agent": h.agent.get() != nil})
|
||||
}
|
||||
|
||||
// healthz is a liveness probe: always returns {"ok": true} when the server is up.
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||
)
|
||||
|
||||
// Instance settings (#79): admin-only, instance-wide. The authoritative admin
|
||||
// check is in the service; requireAdmin here is a cheap early 403 that also
|
||||
// keeps the route group readable.
|
||||
|
||||
// requireAdmin rejects a non-admin actor. It runs after requireAuth, so the
|
||||
// actor is already resolved and carries IsAdmin — no extra query. Returns 403
|
||||
// (not 404): a logged-in user knows settings exist, they just may not touch them.
|
||||
func (h *handlers) requireAdmin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !mustActor(c).IsAdmin {
|
||||
writeAPIError(c, http.StatusForbidden, "FORBIDDEN", "admin access required")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// settingsResponse is what GET/PATCH /settings return. It carries the stored
|
||||
// settings plus a read-only view of what's resolved and live, so the UI can show
|
||||
// "inheriting ollama-cloud/glm-5.2:cloud from the environment" and whether a key
|
||||
// is present — without ever exposing the key itself.
|
||||
type settingsResponse struct {
|
||||
Settings *domain.InstanceSettings `json:"settings"`
|
||||
// Effective is the configuration actually in force after layering settings
|
||||
// over the environment.
|
||||
Effective effectiveView `json:"effective"`
|
||||
}
|
||||
|
||||
type effectiveView struct {
|
||||
Model string `json:"model"`
|
||||
Enabled bool `json:"enabled"`
|
||||
// HasApiKey reports whether OLLAMA_CLOUD_API_KEY is set. The key itself is
|
||||
// never serialized — an admin may know one exists, not what it is.
|
||||
HasApiKey bool `json:"hasApiKey"`
|
||||
// AgentLive is whether the assistant Runner is actually built right now. It
|
||||
// can be false even when Enabled+HasApiKey are true (an unresolvable model),
|
||||
// which is exactly the case the UI needs to surface.
|
||||
AgentLive bool `json:"agentLive"`
|
||||
}
|
||||
|
||||
// settingsPayload builds the response, or an error. It does NOT swallow an
|
||||
// EffectiveAgent failure into a misleading empty "effective" view — an empty
|
||||
// view would report no model and no key, which reads as "nothing configured"
|
||||
// rather than "we couldn't read it". Since EffectiveAgent re-reads the same row
|
||||
// GetInstanceSettings just returned, a failure here is a genuine DB fault worth
|
||||
// surfacing as a 500, not papering over.
|
||||
func (h *handlers) settingsPayload(c *gin.Context, st *domain.InstanceSettings) (settingsResponse, error) {
|
||||
eff, err := h.svc.EffectiveAgent(c.Request.Context())
|
||||
if err != nil {
|
||||
return settingsResponse{}, err
|
||||
}
|
||||
return settingsResponse{
|
||||
Settings: st,
|
||||
Effective: effectiveView{
|
||||
Model: eff.Model,
|
||||
Enabled: eff.Enabled,
|
||||
HasApiKey: eff.APIKey != "",
|
||||
AgentLive: h.agent.get() != nil,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *handlers) getSettings(c *gin.Context) {
|
||||
st, err := h.svc.GetInstanceSettings(c.Request.Context(), mustActor(c).ID)
|
||||
if err != nil {
|
||||
writeServiceError(c, err)
|
||||
return
|
||||
}
|
||||
payload, err := h.settingsPayload(c, st)
|
||||
if err != nil {
|
||||
writeServiceError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
// settingsUpdateRequest is the PATCH body. agentModel "" means inherit the env
|
||||
// var. agentEnabled is json.RawMessage so an explicit null (inherit) is
|
||||
// distinguishable from an absent field and from true/false.
|
||||
type settingsUpdateRequest struct {
|
||||
AgentModel string `json:"agentModel"`
|
||||
AgentEnabled json.RawMessage `json:"agentEnabled"`
|
||||
Version int64 `json:"version" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *handlers) updateSettings(c *gin.Context) {
|
||||
var req settingsUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a current version is required")
|
||||
return
|
||||
}
|
||||
|
||||
// agentEnabled: absent or null → inherit (nil); true/false → explicit override.
|
||||
// The shared parseNullable does exactly this three-way decode; present is
|
||||
// irrelevant here because absent and null both mean "inherit".
|
||||
enabled, _, err := parseNullable[bool](req.AgentEnabled)
|
||||
if err != nil {
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "agentEnabled must be true, false, or null")
|
||||
return
|
||||
}
|
||||
|
||||
st, err := h.svc.UpdateInstanceSettings(c.Request.Context(), mustActor(c).ID, service.InstanceSettingsPatch{
|
||||
AgentModel: req.AgentModel,
|
||||
AgentEnabled: enabled,
|
||||
Version: req.Version,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrVersionConflict) {
|
||||
writeVersionConflict(c, st)
|
||||
return
|
||||
}
|
||||
writeServiceError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Apply the change to the LIVE assistant. Detached from the request context:
|
||||
// the write is committed and the rebuild describes it, so a client that hangs
|
||||
// up now must not leave the running Runner out of step with the stored
|
||||
// settings. Mirrors the same reasoning as the history-write detachment.
|
||||
h.agent.rebuild(context.WithoutCancel(c.Request.Context()))
|
||||
|
||||
payload, err := h.settingsPayload(c, st)
|
||||
if err != nil {
|
||||
writeServiceError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||
)
|
||||
|
||||
// agentCfg is a config with the assistant configured — a (fake) key, on, and a
|
||||
// resolvable model. The key isn't real, but ValidateAgentModel/NewRunner only
|
||||
// PARSE the spec (no live call), so a Runner still builds and capabilities
|
||||
// reports it live. That's enough to exercise the runtime on/off swap.
|
||||
func agentCfg() *config.Config {
|
||||
c := localCfg()
|
||||
c.Agent = config.AgentConfig{
|
||||
Model: "ollama-cloud/glm-5.2:cloud",
|
||||
OllamaCloudAPIKey: "test-key",
|
||||
Enabled: true,
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func settingsVersion(t *testing.T, r *gin.Engine, cookie *http.Cookie) int64 {
|
||||
t.Helper()
|
||||
w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get settings: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
st, _ := decodeMap(t, w.Body.Bytes())["settings"].(map[string]any)
|
||||
return int64(st["version"].(float64))
|
||||
}
|
||||
|
||||
// TestSettingsAdminOnly: the first registered user is admin and can read/write
|
||||
// settings; a second user is not and gets 403 (not 404 — settings aren't a
|
||||
// masked resource).
|
||||
func TestSettingsAdminOnly(t *testing.T) {
|
||||
r := authEngine(t, localCfg())
|
||||
admin := registerAndCookie(t, r, "[email protected]") // first user → admin
|
||||
member := registerAndCookie(t, r, "[email protected]")
|
||||
|
||||
if w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, admin); w.Code != http.StatusOK {
|
||||
t.Fatalf("admin GET settings: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
if w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, member); w.Code != http.StatusForbidden {
|
||||
t.Errorf("member GET settings: status %d, want 403", w.Code)
|
||||
}
|
||||
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||
map[string]any{"agentModel": "x", "version": 1}, member); w.Code != http.StatusForbidden {
|
||||
t.Errorf("member PATCH settings: status %d, want 403", w.Code)
|
||||
}
|
||||
// Unauthenticated is 401, before the admin check.
|
||||
if w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, nil); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("anonymous GET settings: status %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsInheritFromEnv: an untouched instance reports the env model as
|
||||
// effective, and an empty stored model keeps inheriting it.
|
||||
func TestSettingsInheritFromEnv(t *testing.T) {
|
||||
r := authEngine(t, agentCfg())
|
||||
admin := registerAndCookie(t, r, "[email protected]")
|
||||
|
||||
w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, admin)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
body := decodeMap(t, w.Body.Bytes())
|
||||
st := body["settings"].(map[string]any)
|
||||
eff := body["effective"].(map[string]any)
|
||||
|
||||
if st["agentModel"] != "" {
|
||||
t.Errorf("stored model = %v, want empty (inherit)", st["agentModel"])
|
||||
}
|
||||
if eff["model"] != "ollama-cloud/glm-5.2:cloud" {
|
||||
t.Errorf("effective model = %v, want the env value", eff["model"])
|
||||
}
|
||||
if eff["hasApiKey"] != true || eff["agentLive"] != true {
|
||||
t.Errorf("effective = %+v, want a key present and the agent live", eff)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsUpdateSwapsTheRunner is the core of #79: changing settings takes
|
||||
// effect on the LIVE assistant, with no restart. Driven entirely through HTTP.
|
||||
func TestSettingsUpdateSwapsTheRunner(t *testing.T) {
|
||||
r := authEngine(t, agentCfg())
|
||||
admin := registerAndCookie(t, r, "[email protected]")
|
||||
|
||||
capsAgent := func() bool {
|
||||
w := doJSON(t, r, http.MethodGet, "/api/v1/capabilities", nil, admin)
|
||||
return decodeMap(t, w.Body.Bytes())["agent"] == true
|
||||
}
|
||||
|
||||
// Configured and on out of the box.
|
||||
if !capsAgent() {
|
||||
t.Fatal("assistant should be live at boot with a key + enabled")
|
||||
}
|
||||
|
||||
// Turn it OFF via settings → capabilities flips immediately.
|
||||
v := settingsVersion(t, r, admin)
|
||||
w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||
map[string]any{"agentModel": "", "agentEnabled": false, "version": v}, admin)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("disable: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if capsAgent() {
|
||||
t.Error("assistant still live after being disabled — the runner wasn't swapped")
|
||||
}
|
||||
// Chat now refuses, at runtime, on a route that still exists.
|
||||
gid := createGardenAPI(t, r, admin, "G")
|
||||
if w := doJSON(t, r, http.MethodPost, "/api/v1/agent/chat",
|
||||
map[string]any{"gardenId": gid, "message": "hi"}, admin); w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("chat while disabled: status %d, want 503", w.Code)
|
||||
}
|
||||
|
||||
// Turn it back ON, with an explicit model, and confirm it's live again and the
|
||||
// effective model reflects the change.
|
||||
v = settingsVersion(t, r, admin)
|
||||
w = doJSON(t, r, http.MethodPatch, "/api/v1/settings", map[string]any{
|
||||
"agentModel": "ollama-cloud/kimi-k2.6:cloud", "agentEnabled": true, "version": v,
|
||||
}, admin)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("re-enable: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if eff := decodeMap(t, w.Body.Bytes())["effective"].(map[string]any); eff["model"] != "ollama-cloud/kimi-k2.6:cloud" {
|
||||
t.Errorf("effective model = %v after change, want the new one", eff["model"])
|
||||
}
|
||||
if !capsAgent() {
|
||||
t.Error("assistant not live after being re-enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsRejectsBadModel: a spec that won't resolve is a 400 at save time,
|
||||
// not a broken assistant on the next turn.
|
||||
func TestSettingsRejectsBadModel(t *testing.T) {
|
||||
r := authEngine(t, agentCfg())
|
||||
admin := registerAndCookie(t, r, "[email protected]")
|
||||
v := settingsVersion(t, r, admin)
|
||||
|
||||
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||
map[string]any{"agentModel": "nonesuch/model", "version": v}, admin); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("bad model: status %d, want 400", w.Code)
|
||||
}
|
||||
// agentEnabled must be a bool or null, not a string.
|
||||
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||
map[string]any{"agentModel": "", "agentEnabled": "yes", "version": v}, admin); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("string agentEnabled: status %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsVersionConflict: a stale version 409s and carries the current row.
|
||||
func TestSettingsVersionConflict(t *testing.T) {
|
||||
r := authEngine(t, agentCfg())
|
||||
admin := registerAndCookie(t, r, "[email protected]")
|
||||
v := settingsVersion(t, r, admin)
|
||||
|
||||
// First write succeeds and bumps the version.
|
||||
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||
map[string]any{"agentModel": "", "agentEnabled": false, "version": v}, admin); w.Code != http.StatusOK {
|
||||
t.Fatalf("first update: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
// Reusing the old version conflicts.
|
||||
w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||
map[string]any{"agentModel": "", "agentEnabled": true, "version": v}, admin)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("stale update: status %d, want 409", w.Code)
|
||||
}
|
||||
if cur, ok := decodeMap(t, w.Body.Bytes())["current"].(map[string]any); !ok || cur["version"].(float64) != float64(v+1) {
|
||||
t.Errorf("409 body missing the current row at the bumped version: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsSwapUnderRace runs settings saves concurrently with chat requests,
|
||||
// so `go test -race` proves the atomic swap of the live Runner is safe against
|
||||
// in-flight readers. The whole point of the atomic.Pointer is this: without it,
|
||||
// toggling the assistant while a request reads it is a data race.
|
||||
func TestSettingsSwapUnderRace(t *testing.T) {
|
||||
r := authEngine(t, agentCfg())
|
||||
admin := registerAndCookie(t, r, "[email protected]")
|
||||
|
||||
done := make(chan struct{})
|
||||
// Readers hammer capabilities, whose h.agent.get() is the SAME atomic Load the
|
||||
// chat handler does — so this races the pointer read against the writer's swap
|
||||
// without ever invoking the model (a real Run would hit the network on a fake
|
||||
// key). If get() is race-clean here it is race-clean in chat.
|
||||
for i := 0; i < 4; i++ {
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
doJSON(t, r, http.MethodGet, "/api/v1/capabilities", nil, admin)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
// Writer: flip the assistant on and off, swapping the pointer each time.
|
||||
for i := 0; i < 12; i++ {
|
||||
v := settingsVersion(t, r, admin)
|
||||
doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||
map[string]any{"agentModel": "", "agentEnabled": i%2 == 0, "version": v}, admin)
|
||||
}
|
||||
close(done)
|
||||
}
|
||||
Reference in New Issue
Block a user