Everything below the run loop already existed. This is the thing that runs a
model.
The build tag is gone, deliberately. internal/agent's doc comment promised two
separations — cmd/pansy not importing the package, and the tool wiring behind
//go:build majordomo — and both have been rewritten rather than left as a stale
aspiration. A tag that keeps the agent out of the binary only earns its keep if
you would ever ship a build without the agent, and the agent is the point;
keeping it meant an untagged CI that never compiled the code that matters.
majordomo is a real dependency now, resolved from the Gitea instance as a
pseudo-version with no replace directive, so the Docker build (which has no
sibling checkout) resolves it the same way this machine does. It is stdlib-first
and pure Go, so CGO_ENABLED=0 and the single static binary survive.
A TURN IS ONE CHANGE SET. That is the whole reason acting without a confirmation
prompt is defensible: "empty the garlic bed and plant cucumbers" is one object
edit and a dozen planting inserts, and it has to undo as one action rather than
thirteen. The scope is opened even for a turn that turns out to be a question,
because a change set with no revisions is never written — so asking costs
nothing and history isn't littered with empty entries.
The model spec goes to majordomo.Parse verbatim. That grammar, including
comma-separated failover chains, is majordomo's; re-implementing any of it here
would only mean two places to update when it grows. The key needs a bridge
though: majordomo's ollama-cloud preset reads OLLAMA_API_KEY while pansy (like
gadfly) is configured with OLLAMA_CLOUD_API_KEY, so the provider is registered
explicitly on a private registry rather than depending on ambient environment.
Runs are bounded by a step cap, a timeout and majordomo's loop guards. This is
loop safety, not cost control — pansy is a personal tool and spend caps are
explicitly not a v2 concern. A capped run does NOT fail: it kept whatever it
managed to do, that work is recorded and undoable, and the reply says it stopped
early rather than going silent.
The chat endpoint streams. A turn that clears a bed and replants it makes a
dozen tool calls over tens of seconds, and without streaming that is a long
silence followed by everything at once — which reads as a hang, and defeats a
design that rests on watching the canvas change as it happens.
Conversations persist per (user, garden). Client-held history would be lost on a
refresh, which is exactly when someone reloads to check whether the agent's
change landed. Only the user/assistant TEXT is stored, not the model's full
transcript: continuity needs what was said and what came back, and replaying a
stored tool call would replay a decision made against a garden that has since
moved on. It also keeps majordomo's message shape out of the schema.
An instance with no key starts, serves the app, and doesn't advertise the agent
— the routes aren't registered at all, the same shape as OIDC 404ing when
unconfigured. A configured-but-unresolvable model logs and disables the
assistant rather than refusing to boot: a garden planner that won't start
because of a chat feature is worse than one without chat.
Tool refusals reach the model as tool results it can explain, not 500s. The ACL
story only works if it can narrate the refusal.
Closes#56
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
Per-garden public read-only link: unauthenticated GET /api/v1/public/gardens/:token (no requireAuth, no OIDC), owner-only enable/rotate/disable, and a /g/$token page rendering GardenCanvas read-only. Review fixes: redact plant owner ids, Cache-Control: no-store, 400 on malformed body, shared resetTransient store action.
Closes#41.
Co-authored-by: Steve Dudenhoeffer <[email protected]>
Fixes from the PR #28 adversarial review (considered; not graded).
Correctness / API
- PATCH /objects/:id can now clear nullable color/props back to NULL: the
request takes them as json.RawMessage, and ObjectPatch carries an explicit
Set flag so an explicit `null` (clear) is distinguished from an absent
field (unchanged) — the strongest cross-model finding (6 hits). New test.
Maintainability
- store/plantings.go + plants.go use explicit qualified column lists
(qualifyColumns helper) instead of SELECT *, matching gardens/objects and
surviving a future column add.
- Consolidated objectKinds + plantableByDefault into one kind→traits map.
- objectForRole factors the fetch-then-authorize shared by UpdateObject and
DeleteObject; dropped the redundant kind check in CreateObject
(finalizeObject is the single validation point).
- Request→service mapping via toInput()/toPatch() methods (matches gardens).
- Renamed handler gardenFull → getGardenFull (verbNoun); test helper
decodeGarden → decodeMap; generic bind-error messages.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
Garden objects (beds, bags, containers, in-ground, trees, paths,
structures) in one polymorphic table, following the #7 service
conventions.
- store/objects.go: scanObject + Create (RETURNING), Get, ListForGarden
(z_index order), version-guarded Update (RETURNING, returns current row
on conflict), Delete (plantings cascade via FK).
- store/plantings.go + plants.go: the read side /full needs now —
ListActivePlantingsForGarden (removed_at IS NULL) and
ListReferencedPlants (distinct plants used by active plantings). Both
return empty until #14 adds plantings; #12/#14 extend these files.
- service/objects.go: Create/Update(partial patch)/Delete/GardenFull, all
(ctx, actorID, ...) through requireGardenRole(editor for mutations,
viewer for /full). finalizeObject validates kind + shape (rect/circle;
polygon reserved), finite dims in [1cm,100m], NaN/Inf rejection, loose
bbox-overlaps-garden placement (partial overhang OK), hex color, valid
JSON props, name/notes caps; normalizes rotation to [0,360). Plantable
defaults by kind, overridable.
- api/objects.go: POST /gardens/:id/objects, PATCH/DELETE /objects/:id,
GET /gardens/:id/full ({garden,objects,plantings,plants}). props is any
JSON value stored as text; PATCH is partial + required version, 409
returns the current row.
Tests: service (defaults, plantable-by-kind, rotation normalize,
validation rejects incl. off-field/polygon/bad-color/bad-props, partial
patch + version conflict, cross-user ErrNotFound, delete, /full shape) and
api (CRUD + /full, 409 envelope, cross-user 404, polygon/no-kind 400,
props round-trip).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
Establishes the patterns every later backend issue copies: the actor
parameter, centralized role checks, and the version-guard/409 sync
protocol. The service layer is the seam both REST handlers and future
agent tools call, so permissions live here, not in handlers.
- service/gardens.go: Service methods take (ctx, actorID, args).
requireGardenRole(ctx, actor, gardenID, min) is THE authorization point
— owner is implicit via owner_id now; #16 extends it to consult
garden_shares. A user with no role gets ErrNotFound (existence masked),
not ErrForbidden. Create/Get/List/Update/Delete with input validation
(name required, 0 dims default to 10 m on create / rejected on update,
negatives always rejected, unit metric|imperial, 100 m cap).
- store/gardens.go: version-guarded UPDATE ... WHERE id=? AND version=?
RETURNING; a no-match re-reads to return (current row,
ErrVersionConflict) vs ErrNotFound. ListGardensForOwner returns a
non-nil slice.
- api/gardens.go: GET,POST /gardens and GET,PATCH,DELETE /gardens/:id
behind requireAuth. writeVersionConflict documents the 409 envelope
({error:{code,message}, current:{...}}) — the contract for every
mutable resource. writeResourceError maps ErrNotFound/Forbidden/
InvalidInput/VersionConflict; parseIDParam guards path ids.
Tests: service (defaults, validation, owned-only list, version
conflict returns current + retry, cross-user ErrNotFound, delete) and
api (full CRUD flow, 409 envelope shape, cross-user 404, auth required,
create validation). Verified against the running binary: create stores
imperial 122x244 cm and list returns it.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
OIDC is pansy's primary login path (Authentik the target IdP); local
auth (#4) remains the fallback and both issue the same session cookie.
- deps: github.com/coreos/go-oidc/v3 + golang.org/x/oauth2 (both pure Go;
CGO stays off).
- api/oidc.go: lazy issuer discovery (retried per-request, never crashes
a server that also serves local auth), GET /auth/oidc/login builds an
authorization-code URL with PKCE S256 + random state + nonce stashed in
a short-lived HttpOnly cookie, GET /auth/oidc/callback verifies state
(constant-time), exchanges the code with the PKCE verifier, verifies the
ID token + nonce, and starts a pansy session. Failures redirect to
/login?error=... ; success to /gardens.
- service.LoginOIDC: (issuer,subject) match -> login; else *verified*
email match -> link onto the existing account; else JIT-create (the IdP
gates access, so PANSY_REGISTRATION doesn't apply). Unverified email
colliding with an existing account is refused (takeover guard); no email
is refused (email is the account key). Reuses the atomic CreateUser.
- store: GetUserByOIDC + LinkOIDC (unique-pair backstop).
- config: OIDCReady() (needs issuer+client+BaseURL for the redirect URI);
/auth/providers now reports oidc from it and defaults the button label
to "Sign in with Authentik". OIDC routes are only registered when ready,
so an unconfigured instance 404s them.
- PANSY_LOCAL_AUTH=false rejects/hides local auth but not OIDC.
Tests: service provisioning (JIT, repeat login, link, unverified-collision
refusal, no-email, name fallback, works with local auth off); api
(routes-absent-when-unconfigured, providers reporting, login redirect with
PKCE params + tx cookie via a fake discovery server, callback state/error
paths). Smoke-tested: unreachable issuer degrades to error=oidc_unavailable
with the server still up and local auth working.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
Fixes from the PR #23 adversarial review (graded 35 real / 1 false positive):
Security / correctness
- Race-free registration: is_admin and the registration gate are now
computed atomically inside a single INSERT...SELECT, so concurrent
first registrations can't both become admin or bypass closed
registration (fixed the whole TOCTOU cluster).
- Sliding session now reaches the browser: ResolveSession returns the
current expiry and requireAuth re-sets the cookie, so active users
aren't logged out 30 days after login regardless of activity.
- Login CSRF: csrfGuard rejects state-changing requests whose Origin
doesn't match PANSY_BASE_URL (no-op when unset, so the dev proxy is
unaffected). SameSite=Lax alone didn't cover this.
- argon2id tuned to RFC 9106's second recommended profile (t=3).
- Timing equalizer can't fail open: the dummy hash is derived
deterministically (fixed salt, no RNG) so it's always present.
- Password length (<=1024) enforced in the service for both register
and login, not just HTTP binding tags; login rejects over-long input
before spending argon2 work.
Error handling / robustness
- Login logs a malformed stored hash instead of silently treating it as
a wrong password.
- Best-effort session writes (Touch/Delete during renewal, expiry, and
corrupt-expiry cleanup) now log on failure.
- index sessions.expires_at via new migration 0002 (0001 is immutable).
Maintainability
- Extract startSessionAndRespond and abortUnauthenticated; make
writeServiceError a free function; consistent error handling in
decodeHash; doc/comment fixes.
Tests: over-long password, CSRF guard (cross-origin/same-origin/dev
no-op), and cookie refresh on authenticated requests; migration-version
assertions bumped to 2.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
Implements pansy's local (email + password) authentication and the
session layer that OIDC (#5) will also reuse.
- store: users.go (create/get-by-id/get-by-email/count) and sessions.go
(create/get/touch/delete/delete-expired), scanning the existing 0001
schema.
- service: the business-logic seam. auth.go (Register/Login/session
lifecycle/Providers) + password.go (argon2id, 64 MiB/1/4, PHC-encoded,
constant-time verify) + service.go (Service, clock injection, token
hashing). First user is admin; closed registration still allows the
bootstrap user; unknown-email and wrong-password are indistinguishable
(same error, same argon2 work via a dummy hash).
- api: POST /auth/register|login|logout, GET /auth/me|providers, plus a
requireAuth middleware that resolves the HttpOnly session cookie
(SameSite=Lax, Secure under https) to the actor. Handlers stay thin.
- main: wires the service and a periodic expired-session sweep; sessions
are also dropped lazily on access. Sliding 30-day expiry.
- tests: service (register/login/expiry/renewal/cleanup, password) and
api (cookie flow, middleware, validation, providers).
Verified end-to-end via curl: register -> me -> restart -> session
persists -> logout -> 401.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
Applied the fixes warranted by the adversarial review of PR #21 (all
findings graded in the gadfly store):
Store (highest impact):
- buildDSN always merges busy_timeout/journal_mode/foreign_keys pragmas
into the DSN, even for file: URIs or paths with a query string — the
prior passthrough silently disabled FK enforcement for those PANSY_DB
values. Also escape '#' in the path and tighten in-memory detection to
an exact ':memory:' token or mode=memory param (no substring misfire).
- migrate.go: detect duplicate migration versions with a clear error;
wrap appliedVersions' rows.Err() with the store: prefix.
API:
- SetTrustedProxies failure now falls back to trust-none instead of
leaving gin's trust-everyone default (X-Forwarded-For spoofing).
- SPA: 404 embedded directories (was a directory listing), 404 bare /api,
add X-Content-Type-Options: nosniff, cache index.html bytes once at
startup, single fs.Stat existence check, shared writeAPIError helper.
- Move gin.SetMode out of package init() into New().
Config: validate PANSY_PORT range (fall back to 8080 with a warning).
Web:
- api.ts: serialize the request body before the fetch try so a
JSON.stringify failure isn't misreported as a network error.
- AppShell: move state-specific/conflicting utilities into
active/inactiveProps so concatenated Tailwind classes don't collide.
Tests: added store DSN-pragma/memory-detection cases and SPA
directory-404 case. go build/vet/test clean (CGO off); web tsc + build
clean; re-verified in a browser (SPA, deep links, /assets 404, nav).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
Implements the Pansy v1 scaffold (epic #20, phase 0).
#1 Backend: Go module (pure-Go, builds with CGO_ENABLED=0), env config,
gin server with slog + recovery + /api/v1/healthz, modernc SQLite store
with WAL/busy_timeout/foreign_keys pragmas, embedded numbered-migration
runner, full initial schema (users, sessions, gardens, garden_shares,
garden_objects, plants, plantings), domain structs + sentinel errors,
graceful shutdown.
#2 Frontend: Vite + React 19 + TS (strict) + Tailwind 4 + TanStack
Router/Query, typed /api/v1 fetch wrapper (ApiError carries status +
body so later issues can read 409 conflict rows), dev proxy /api -> :8080,
responsive app shell with stub pages for all five routes.
#3 Single binary: //go:embed of the web build with a committed placeholder,
SPA fallback (deep links, immutable asset caching, JSON 404 for unmatched
/api and missing assets), Makefile (web/build/dev/test), README quickstart
+ env var table.
Verified: go build/vet/test clean (CGO off); binary migrates idempotently and
serves healthz; web tsc + build clean; integrated binary serves the SPA, deep
links, and correct cache headers; app mounts and navigates all five routes at
desktop and 375px widths.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi