Admin-gated Settings: runtime model selection, enforce is_admin (#79)
Moves the agent model out of env-only config into an admin-editable Settings
section, and enforces is_admin for the first time — it has been in the schema
since migration 0001, plumbed to the client, and checked nowhere.
Backend:
- Migration 0010: instance_settings, a single-row (CHECK id=1) table — pansy's
first instance-level state. Holds agent_model ('' = inherit env) and
agent_enabled (NULL = inherit env), version-guarded like every mutable row.
SECRETS STAY IN ENV: OLLAMA_CLOUD_API_KEY is never stored here.
- requireAdmin at the service seam (authoritative) plus a cheap middleware
early-403. Non-admin gets 403, not 404 — settings existence isn't masked.
- EffectiveAgent resolves DB-over-env (model, enabled); key always from env.
- The live Runner is hot-swapped, not built once. agentHolder holds it behind
an atomic.Pointer; the chat routes are now registered UNCONDITIONALLY and
nil-check agent.get(), so a settings change turns the assistant on/off/onto a
new model with no restart and no race against in-flight readers. /capabilities
reads the pointer, so it reports what's live, not what booted.
- internal/agentmodel is a new leaf package holding the one place that knows how
to turn a spec into a model. Both agent (to run) and service (to validate a
spec before storing it) import it; it can't live in agent, which imports
service. Settings PATCH validates the spec via Parse, so a typo is a 400 now
rather than a broken assistant on the next turn.
Frontend:
- /settings route (admin guard), a Settings page (model field, tri-state
enabled, live status), nav link shown only to admins.
- useCapabilities drops staleTime:Infinity — the assistant can now change under
a running page — and the settings save invalidates it.
Contract change: chat routes always exist, so "assistant off" is a runtime 503
+ capabilities:false, not a missing route. Updated the test that asserted the
old shape.
Verified live against the built binary: disable flips capabilities to false and
logs it; re-enable with a new model swaps it back; a bad spec is rejected 400;
the setting persists across a restart. Swap is race-clean under `go test -race`.
Docs: README (precedence + key-stays-in-env), DESIGN (decision + routes),
CLAUDE (don't re-add conditional route registration; key never in the DB).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agentmodel"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
)
|
||||
|
||||
// Instance settings (#79): admin-editable, instance-wide configuration. The
|
||||
// admin gate lives HERE, in the seam, not in the handler — the same rule every
|
||||
// other permission follows. The API's requireAdmin middleware is a cheap early
|
||||
// 403, not the authority.
|
||||
|
||||
// requireAdmin returns nil iff the actor is an admin, else ErrForbidden.
|
||||
//
|
||||
// ErrForbidden, not ErrNotFound: settings are not a resource whose existence is
|
||||
// masked. A logged-in non-admin knows the instance has settings; they simply may
|
||||
// not touch them. (Contrast objects/lots, where no-access masks existence.)
|
||||
func (s *Service) requireAdmin(ctx context.Context, actorID int64) error {
|
||||
u, err := s.store.GetUserByID(ctx, actorID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !u.IsAdmin {
|
||||
return domain.ErrForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetInstanceSettings returns the instance settings for an admin.
|
||||
func (s *Service) GetInstanceSettings(ctx context.Context, actorID int64) (*domain.InstanceSettings, error) {
|
||||
if err := s.requireAdmin(ctx, actorID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.GetInstanceSettings(ctx)
|
||||
}
|
||||
|
||||
// InstanceSettingsPatch is a full replacement of the editable fields plus the
|
||||
// current version. Both agent fields carry their "inherit" sentinel: an empty
|
||||
// AgentModel means fall back to env, a nil AgentEnabled means inherit.
|
||||
type InstanceSettingsPatch struct {
|
||||
AgentModel string
|
||||
AgentEnabled *bool
|
||||
Version int64
|
||||
}
|
||||
|
||||
// UpdateInstanceSettings applies an admin's change, version-guarded. It returns
|
||||
// (current row, ErrVersionConflict) on a stale version, like every mutable
|
||||
// resource. The model spec is validated before it is stored, so a typo is a 400
|
||||
// now rather than a broken assistant on the next turn.
|
||||
//
|
||||
// It does NOT rebuild the running agent — that is the API layer's job, because
|
||||
// the live Runner lives there. The caller rebuilds on success.
|
||||
func (s *Service) UpdateInstanceSettings(ctx context.Context, actorID int64, patch InstanceSettingsPatch) (*domain.InstanceSettings, error) {
|
||||
if err := s.requireAdmin(ctx, actorID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
model := strings.TrimSpace(patch.AgentModel)
|
||||
// Validate a non-empty spec up front. An empty one is the "inherit env"
|
||||
// sentinel and needs no check — the env value was validated at boot.
|
||||
if model != "" {
|
||||
if err := agentmodel.Validate(s.cfg.Agent.OllamaCloudAPIKey, model); err != nil {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
}
|
||||
return s.store.UpdateInstanceSettings(ctx, &domain.InstanceSettings{
|
||||
AgentModel: model,
|
||||
AgentEnabled: patch.AgentEnabled,
|
||||
Version: patch.Version,
|
||||
})
|
||||
}
|
||||
|
||||
// EffectiveAgent resolves the agent configuration actually in force: DB settings
|
||||
// override the environment, and the API key always comes from the environment.
|
||||
//
|
||||
// This is internal plumbing (the API layer calls it to build the Runner), NOT an
|
||||
// admin-gated operation — resolving what's configured is not the same as editing
|
||||
// it, and the agent bootstrap must work before any user is even authenticated.
|
||||
type EffectiveAgent struct {
|
||||
Model string
|
||||
Enabled bool
|
||||
APIKey string
|
||||
}
|
||||
|
||||
// Ready mirrors config.AgentConfig.Ready: enabled, with a key and a model.
|
||||
func (e EffectiveAgent) Ready() bool {
|
||||
return e.Enabled && e.APIKey != "" && e.Model != ""
|
||||
}
|
||||
|
||||
// EffectiveAgent reads the settings row and layers it over the environment.
|
||||
func (s *Service) EffectiveAgent(ctx context.Context) (EffectiveAgent, error) {
|
||||
st, err := s.store.GetInstanceSettings(ctx)
|
||||
if err != nil {
|
||||
return EffectiveAgent{}, err
|
||||
}
|
||||
eff := EffectiveAgent{
|
||||
Model: s.cfg.Agent.Model,
|
||||
Enabled: s.cfg.Agent.Enabled,
|
||||
APIKey: s.cfg.Agent.OllamaCloudAPIKey,
|
||||
}
|
||||
if st.AgentModel != "" {
|
||||
eff.Model = st.AgentModel
|
||||
}
|
||||
if st.AgentEnabled != nil {
|
||||
eff.Enabled = *st.AgentEnabled
|
||||
}
|
||||
return eff, nil
|
||||
}
|
||||
Reference in New Issue
Block a user