Admin-gated Settings: runtime model selection, enforce is_admin (#79)
Gadfly review (reusable) / review (pull_request) Failing after 30s
Adversarial Review (Gadfly) / review (pull_request) Failing after 30s
Build image / build-and-push (push) Successful in 14s

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:
2026-07-21 20:27:36 -04:00
co-authored by Claude Opus 4.8
parent 437c535cd1
commit 41c28592a2
23 changed files with 1302 additions and 93 deletions
+13
View File
@@ -53,6 +53,19 @@ export function AppShell() {
{l.label}
</Link>
))}
{/* Settings is admin-only, matching the server's requireAdmin gate.
A non-admin who typed /settings still gets a 403 from the API — the
hidden link is convenience, not the security boundary. */}
{user?.isAdmin && (
<Link
to="/settings"
className={navLinkBase}
activeProps={{ className: navLinkActive }}
inactiveProps={{ className: navLinkInactive }}
>
Settings
</Link>
)}
</div>
{user ? (
+12 -4
View File
@@ -13,13 +13,21 @@ import { historyKey } from './history'
const capabilitiesSchema = z.object({ agent: z.boolean() })
/** Whether this instance has the assistant configured. Without it the panel
* isn't rendered at all — a dead button is worse than no button. */
export const capabilitiesKey = ['capabilities'] as const
/** Whether the assistant is live RIGHT NOW. Without it the panel isn't rendered
* at all — a dead button is worse than no button.
*
* Not `staleTime: Infinity` any more: an admin can turn the assistant on or off
* in Settings (#79), so this must be able to change under a running page. The
* settings save invalidates this key directly; the finite staleTime just means
* another admin's change is picked up on the next focus/remount rather than
* never. */
export function useCapabilities() {
return useQuery({
queryKey: ['capabilities'] as const,
queryKey: capabilitiesKey,
queryFn: async () => capabilitiesSchema.parse(await api.get('/capabilities')),
staleTime: Infinity, // server config; it doesn't change under a running page
staleTime: 60_000,
})
}
+79
View File
@@ -0,0 +1,79 @@
// Instance settings data layer (#79): admin-only, instance-wide configuration.
//
// The GET/PATCH return both the stored settings and a read-only "effective" view
// — what's actually in force after layering the DB over the environment — so the
// form can say "inheriting ollama-cloud/glm-5.2:cloud from the environment" and
// whether the API key is present, without the key ever crossing the wire.
import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { ApiError, api } from './api'
import { capabilitiesKey } from './agent'
export const instanceSettingsSchema = z.object({
// '' means "inherit the PANSY_AGENT_MODEL env var".
agentModel: z.string(),
// null means "inherit PANSY_AGENT_ENABLED"; true/false is an explicit override.
agentEnabled: z.boolean().nullable(),
version: z.number(),
updatedAt: z.string(),
})
export type InstanceSettings = z.infer<typeof instanceSettingsSchema>
export const effectiveAgentSchema = z.object({
model: z.string(),
enabled: z.boolean(),
hasApiKey: z.boolean(),
agentLive: z.boolean(),
})
export type EffectiveAgent = z.infer<typeof effectiveAgentSchema>
export const settingsResponseSchema = z.object({
settings: instanceSettingsSchema,
effective: effectiveAgentSchema,
})
export type SettingsResponse = z.infer<typeof settingsResponseSchema>
export const settingsKey = ['settings'] as const
export const settingsQueryOptions = queryOptions({
queryKey: settingsKey,
queryFn: async (): Promise<SettingsResponse> =>
settingsResponseSchema.parse(await api.get('/settings')),
})
export function useSettings() {
return useQuery(settingsQueryOptions)
}
export interface SettingsUpdate {
agentModel: string
agentEnabled: boolean | null
version: number
}
export function useUpdateSettings() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (input: SettingsUpdate): Promise<SettingsResponse> =>
settingsResponseSchema.parse(await api.patch('/settings', input)),
onSuccess: (res) => {
qc.setQueryData(settingsKey, res)
// The save may have turned the assistant on or off; the editor keys its
// chat tab off /capabilities, so make it re-read rather than trust its
// cached answer.
qc.invalidateQueries({ queryKey: capabilitiesKey })
},
})
}
/** If err is a 409 version conflict, return the fresh settings it carries so a
* form can rebase; otherwise null. */
export function conflictSettings(err: unknown): InstanceSettings | null {
if (err instanceof ApiError && err.isConflict && err.body && typeof err.body === 'object') {
const current = (err.body as { current?: unknown }).current
const parsed = instanceSettingsSchema.safeParse(current)
if (parsed.success) return parsed.data
}
return null
}
+164
View File
@@ -0,0 +1,164 @@
import { useEffect, useState } from 'react'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { Select } from '@/components/ui/Select'
import { TextField } from '@/components/ui/TextField'
import { toast } from '@/components/ui/toast'
import { errorMessage } from '@/lib/api'
import {
conflictSettings,
useSettings,
useUpdateSettings,
type EffectiveAgent,
type InstanceSettings,
} from '@/lib/settings'
import { usePageTitle } from '@/lib/usePageTitle'
// agentEnabled is a tri-state on the wire (null = inherit env, true, false); the
// form models it as three named choices so "inherit" is a deliberate pick, not
// an empty control.
type EnabledChoice = 'inherit' | 'on' | 'off'
const toChoice = (v: boolean | null): EnabledChoice => (v === null ? 'inherit' : v ? 'on' : 'off')
const fromChoice = (c: EnabledChoice): boolean | null => (c === 'inherit' ? null : c === 'on')
/** Admin-only instance settings (#79). The one that matters today is the agent
* model; the API key stays in the environment and is only ever reported as
* present/absent, never shown or edited. */
export function SettingsPage() {
usePageTitle('Settings')
const settings = useSettings()
const update = useUpdateSettings()
if (settings.isPending) {
return <p className="text-sm text-muted">Loading settings</p>
}
if (settings.isError) {
return <Alert>{errorMessage(settings.error, "Couldn't load settings.")}</Alert>
}
return <SettingsForm data={settings.data} update={update} />
}
function SettingsForm({
data,
update,
}: {
data: { settings: InstanceSettings; effective: EffectiveAgent }
update: ReturnType<typeof useUpdateSettings>
}) {
const [model, setModel] = useState(data.settings.agentModel)
const [enabled, setEnabled] = useState<EnabledChoice>(toChoice(data.settings.agentEnabled))
const [version, setVersion] = useState(data.settings.version)
const [error, setError] = useState<string | null>(null)
// Rebase the form when the query cache updates (e.g. a successful save writes
// the new row back), so the version we submit is never stale.
useEffect(() => {
setVersion(data.settings.version)
}, [data.settings.version])
const eff = data.effective
const save = () => {
setError(null)
update.mutate(
{ agentModel: model.trim(), agentEnabled: fromChoice(enabled), version },
{
onSuccess: () => toast.info('Settings saved.'),
onError: (err) => {
const current = conflictSettings(err)
if (current) {
// Someone else saved first. Adopt their row so the next attempt is
// clean, and say so rather than silently discarding this edit.
setModel(current.agentModel)
setEnabled(toChoice(current.agentEnabled))
setVersion(current.version)
setError('Someone else changed these settings just now — reloaded their version. Re-apply your change if you still want it.')
return
}
setError(errorMessage(err, "Couldn't save settings."))
},
},
)
}
return (
<div className="flex max-w-2xl flex-col gap-6">
<div>
<h1 className="text-xl font-semibold text-fg">Settings</h1>
<p className="mt-1 text-sm text-muted">
Instance-wide, admin-only. Changes take effect immediately no restart.
</p>
</div>
<section className="flex flex-col gap-4 rounded-lg border border-border p-4">
<div>
<h2 className="text-sm font-semibold text-fg">Garden assistant</h2>
<p className="mt-1 text-xs text-muted">
The model runs against your Ollama Cloud key, which is set in the environment and never
shown here.
</p>
</div>
<AgentStatus eff={eff} />
<TextField
label="Model"
name="agentModel"
placeholder={eff.model || 'ollama-cloud/glm-5.2:cloud'}
value={model}
onChange={(e) => setModel(e.target.value)}
hint={
model.trim() === ''
? `Empty — inheriting ${eff.model || 'the built-in default'} from the environment.`
: 'A majordomo model spec. A comma-separated list is a failover chain.'
}
/>
<Select
label="Enabled"
name="agentEnabled"
value={enabled}
onChange={(e) => setEnabled(e.target.value as EnabledChoice)}
options={[
{ value: 'inherit', label: 'Inherit from environment' },
{ value: 'on', label: 'On' },
{ value: 'off', label: 'Off' },
]}
/>
</section>
{error && <Alert>{error}</Alert>}
<div className="flex justify-end">
<Button onClick={save} disabled={update.isPending}>
{update.isPending ? 'Saving…' : 'Save changes'}
</Button>
</div>
</div>
)
}
// AgentStatus surfaces the gap the capabilities endpoint exists for: enabled +
// a key does not guarantee the assistant is actually running (an unresolvable
// model leaves it down), and that's precisely what an admin needs to see.
function AgentStatus({ eff }: { eff: EffectiveAgent }) {
const [tone, text] = eff.agentLive
? (['ok', `Live on ${eff.model}`] as const)
: !eff.hasApiKey
? (['warn', 'No API key set in the environment — the assistant is off.'] as const)
: !eff.enabled
? (['warn', 'Turned off.'] as const)
: (['warn', `Configured but not running — check the model (${eff.model}).`] as const)
return (
<div className="flex items-center gap-2 text-sm">
<span
className={
'inline-block h-2 w-2 rounded-full ' + (tone === 'ok' ? 'bg-emerald-500' : 'bg-amber-500')
}
aria-hidden
/>
<span className={tone === 'ok' ? 'text-fg' : 'text-muted'}>{text}</span>
</div>
)
}
+23
View File
@@ -14,6 +14,7 @@ import { GardensPage } from '@/pages/GardensPage'
import { GardenEditorPage } from '@/pages/GardenEditorPage'
import { PublicGardenPage } from '@/pages/PublicGardenPage'
import { PlantsPage } from '@/pages/PlantsPage'
import { SettingsPage } from '@/pages/SettingsPage'
import { meQueryOptions } from '@/lib/auth'
import { queryClient } from '@/lib/queryClient'
import { safeRedirectPath } from '@/lib/redirect'
@@ -48,6 +49,20 @@ async function requireGuest(context: RouterContext, redirectTo: string) {
}
}
// requireAdmin: authenticated AND admin. A non-admin who navigates to /settings
// is sent to /gardens rather than shown a page whose API calls would 403. This
// is convenience routing, not the security boundary — the server's requireAdmin
// is authoritative.
async function requireAdmin(context: RouterContext, path: string) {
const me = await context.queryClient.ensureQueryData(meQueryOptions)
if (!me) {
throw redirect({ to: '/login', search: { redirect: path } })
}
if (!me.isAdmin) {
throw redirect({ to: '/gardens' })
}
}
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
@@ -109,6 +124,13 @@ const plantsRoute = createRoute({
component: PlantsPage,
})
const settingsRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'settings',
beforeLoad: ({ context, location }) => requireAdmin(context, location.href),
component: SettingsPage,
})
// Public read-only garden by share token. Deliberately has NO beforeLoad auth
// guard, so a logged-out visitor viewing a shared link is never redirected to
// /login or OIDC.
@@ -125,6 +147,7 @@ const routeTree = rootRoute.addChildren([
gardensRoute,
gardenEditorRoute,
plantsRoute,
settingsRoute,
publicGardenRoute,
])