Skip to content

Latest commit

 

History

History
33 lines (23 loc) · 8.84 KB

File metadata and controls

33 lines (23 loc) · 8.84 KB

Manifest config: block — user-supplied app configuration

  • Status: done
  • Date: 2026-06-26
  • Specs touched: APP_MANIFEST.md # D4 (governing spec — implemented, not changed), BRAIN_UI_PROTOCOL.md (install-plan config schema + the config endpoints — implemented), DASHBOARD.md # Install authorization / # Installed apps (implemented), SERVICE_PROVISIONING.md # Env-var injection (implemented), docs/dev/catalog-import-gaps.md (the operator-env-config gap marked closed)

Implements the config: manifest block whose spec landed in #265 (spec/user-supplied-config, APP_MANIFEST.md # D4 + the DECISIONS.md 2026-06-26 entry). Closes #264 and the operator-env-config gap — the single largest class of catalog apps malmo rejected or shipped degraded (every OAuth provider key, an external connection string, a provider/model selector). An app declares values only the user can supply; the brain renders a form at install and a Settings editor on the app's page, and injects each answer directly under the app's own env-var name — no MALMO_* indirection, because the value is the app's own native variable.

What was done

  • internal/manifest/ (manifest.go) — a Config []ConfigField block (AppEnv, Title, Description, Secret, Required, Type text|enum|bool, Options, Default, Service). validateConfig enforces the rules and normalizes Type in place: app_env is a security boundary — a bare uppercase env-var identifier (configEnvName), never the brain-owned MALMO_ prefix, never a loader/runtime-critical var (reservedConfigEnv: PATH, HOME, LD_PRELOAD, …), unique per manifest; options required iff type: enum; secret: true may not carry a default; bool requires a true/false default. service is not checked here (the validator has no compose).
  • internal/store/ (store.go) — a new instance_config table (PK (instance_id, app_env)instances ON DELETE CASCADE, value, secret), an InstanceConfig value type, and SetInstanceConfig (full replace in one tx) / GetInstanceConfig. Plaintext at rest, the same trust model as instance_secrets (row encryption deferred — NEXT.md # App-secret injection hardening); the cascade reclaims rows on uninstall.
  • internal/lifecycle/ (new config.go)configEnvByService buckets stored values by target service (service: or main_service); writeOverride stamps each value verbatim into that service's environment: in the compose override (not the interpolation .env the MALMO_* family uses), so the override wins over any placeholder in the author's compose. SetConfig re-persists + re-stamps and compose ups a running instance (env is read at container create), the brain-commits-first posture (a failed recreate leaves the desired state for reconcile). restampConfigEnv owns the override's environment: block wholly, so a cleared value stops being injected. validateConfigServices is the install-time backstop for a service: that names no real compose service. The Install signature gained a config []store.InstanceConfig param; Door-2 pastes pass nil.
  • internal/api/ (new appconfig.go, install_plan.go, api.go) — the install-plan handler returns the config schema (never a value). POST /apps validates config.fields against the manifest (resolveInstallConfig: required present, enum within options, no unknown app_env) → 422 per-field, persists + stamps before first compose up, and audits app.install success=false on a config rejection. GET /apps/:id/config (owner-or-admin) returns the schema + current state — a secret never returns its value, only set: true|false; a non-secret never-set field pre-fills the manifest default. PUT /apps/:id/config applies a partial update (resolvePutConfig: absent key keeps the stored value, "" clears an optional, required validated against the resulting state so a required secret can be replaced but never blanked), rewrites the override, restarts the app as a job, and audits app.config.update success and failure (elevation-class). New audit action ActionAppConfigUpdate.
  • web-ui (InstallDialog.vue, views/settings/InstalledAppDetailSection.vue, api.ts) — the install consent dialog gains a Setup step: each field shows title + description + a monospace Sets <APP_ENV> hint, with a masked input for secrets, a <select> for enum, a toggle for bool, a text input otherwise; required fields disable the Install button (configComplete) and optional blanks are omitted from the request. The per-app settings page gains a Settings editor: non-secret values edit inline, a secret shows set/not-set with a Replace box, and Save sends only the changed fields (an untouched secret is never resent) → PUT → restart. The generated client (src/generated/openapi.ts) was regenerated.

How it maps to the specs

D4's locked decisions are all realized: inject under the app's own var with no MALMO_* indirection; app_env is parse- and install-validated as a security boundary; required fields gate the install button; optional blanks inject nothing (declare-and-degrade); secrets are handled like MALMO_SECRET_* (never returned, never logged, masked in the UI); POST validates → 422 before any job; PUT is a partial update so a client can't blank a secret it can't read; both endpoints take the app-control gate; config mutations audit success and failure.

Known gaps & deviations

  • Config-validation 422s audit (deliberate, matches installApp). Self-review flagged that CLAUDE.md # Go code discipline says "validation 422s don't audit," yet POST /apps (config rejection) and PUT /apps/:id/config record success=false on a 422. This is intentional: the pre-existing installApp already audits its folder/mail consent-screen 422 rejections as app.install failures (api.go, "elevation-class mutation rejection, so it audits success=false"), and the config field is resolved on the same consent screen by the same handler — auditing it lets the Activity view answer "who tried to install/reconfigure with a bad token?", the same value login.failure gives. So the convention's "validation 422s don't audit" carve-out is really about non-elevation request-shape 422s; elevation-class consent/config-screen rejections audit. Left as a documented deviation rather than de-aligning #264 from installApp.
  • No full catalog re-screen in this PR. The operator-env-config gap entry (catalog-import-gaps.md) is marked closed at the mechanism level, but re-importing each previously-blocked app (browser-use, hayhooks, dub, cube, …) is per-app work — each needs its own catalog issue and a live boot. Postiz stays blocked on an unrelated gap (nonroot-data-ownership — postiz).
  • No catalog example app shipped here. The issue floated shipping one config:-exercising app (e.g. an OpenAI-key app) as live verification; that is a separate catalog addition (starts from a Catalog app issue) and is left as follow-up. The block is exercised end-to-end by the api + lifecycle tests instead.
  • Plaintext at rest. Config values (including secrets) are stored plaintext, matching instance_secrets; row-level encryption is the deferred NEXT.md # App-secret injection hardening item, not this slice.
  • A failed config recreate doesn't self-converge a running container. SetConfig is brain-commits-first (store + override hold the desired state, then compose up -d). If that recreate fails, the job reports failure and the override already holds the desired config, so a container that fell over — or a later brain restart — is brought back up against it by the reconcile pass. But reconcile re-creates an already-running container only on resource-limit drift, not config drift, so a container that keeps running with the old env stays on it until the user retries the edit. This matches RebindMail and the documented "host is reconstructible" posture (CLAUDE.md); a rollback-on-failure was deliberately not added because compose up runs under healthWait and a health-timeout "failure" can leave the container already recreated on the new env — reverting the store/override there would itself create drift. Greptile flagged this (P1/P2); the comments on SetConfig/RebindMail were corrected to describe the real convergence boundary rather than overclaim. Reconcile-level config-drift convergence is a possible future hardening, tracked nowhere yet because it spans config + mail.

What's next

  • Re-import the env-config-blocked apps. With the mechanism in place, file Catalog app issues for the apps operator-env-config named and author each config: block against the live block.
  • Ship a config: catalog example as a one-app live verification (OpenAI-key app), per the issue.
  • At-rest hardening for instance_config + instance_secrets together (NEXT.md).