- Status: done
- Date: 2026-06-26
- Specs touched:
APP_MANIFEST.md# D4 (governing spec — implemented, not changed),BRAIN_UI_PROTOCOL.md(install-planconfigschema + theconfigendpoints — implemented),DASHBOARD.md# Install authorization / # Installed apps (implemented),SERVICE_PROVISIONING.md# Env-var injection (implemented),docs/dev/catalog-import-gaps.md(theoperator-env-configgap 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.
internal/manifest/(manifest.go) — aConfig []ConfigFieldblock (AppEnv,Title,Description,Secret,Required,Typetext|enum|bool,Options,Default,Service).validateConfigenforces the rules and normalizesTypein place:app_envis a security boundary — a bare uppercase env-var identifier (configEnvName), never the brain-ownedMALMO_prefix, never a loader/runtime-critical var (reservedConfigEnv:PATH,HOME,LD_PRELOAD, …), unique per manifest;optionsrequired ifftype: enum;secret: truemay not carry adefault;boolrequires atrue/falsedefault.serviceis not checked here (the validator has no compose).internal/store/(store.go) — a newinstance_configtable (PK(instance_id, app_env)→instancesON DELETE CASCADE,value,secret), anInstanceConfigvalue type, andSetInstanceConfig(full replace in one tx) /GetInstanceConfig. Plaintext at rest, the same trust model asinstance_secrets(row encryption deferred —NEXT.md# App-secret injection hardening); the cascade reclaims rows on uninstall.internal/lifecycle/(newconfig.go) —configEnvByServicebuckets stored values by target service (service:ormain_service);writeOverridestamps each value verbatim into that service'senvironment:in the compose override (not the interpolation.envtheMALMO_*family uses), so the override wins over any placeholder in the author's compose.SetConfigre-persists + re-stamps andcompose ups a running instance (env is read at container create), the brain-commits-first posture (a failed recreate leaves the desired state for reconcile).restampConfigEnvowns the override'senvironment:block wholly, so a cleared value stops being injected.validateConfigServicesis the install-time backstop for aservice:that names no real compose service. TheInstallsignature gained aconfig []store.InstanceConfigparam; Door-2 pastes passnil.internal/api/(newappconfig.go,install_plan.go,api.go) — the install-plan handler returns theconfigschema (never a value).POST /appsvalidatesconfig.fieldsagainst the manifest (resolveInstallConfig: required present, enum within options, no unknownapp_env) →422per-field, persists + stamps before firstcompose up, and auditsapp.installsuccess=false on a config rejection.GET /apps/:id/config(owner-or-admin) returns the schema + current state — a secret never returns its value, onlyset: true|false; a non-secret never-set field pre-fills the manifest default.PUT /apps/:id/configapplies 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 auditsapp.config.updatesuccess and failure (elevation-class). New audit actionActionAppConfigUpdate.- web-ui (
InstallDialog.vue,views/settings/InstalledAppDetailSection.vue,api.ts) — the install consent dialog gains a Setup step: each field shows title + description + a monospaceSets <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.
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.
- Config-validation 422s audit (deliberate, matches
installApp). Self-review flagged thatCLAUDE.md# Go code discipline says "validation 422s don't audit," yetPOST /apps(config rejection) andPUT /apps/:id/configrecordsuccess=falseon a 422. This is intentional: the pre-existinginstallAppalready audits its folder/mail consent-screen 422 rejections asapp.installfailures (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 valuelogin.failuregives. 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 frominstallApp. - No full catalog re-screen in this PR. The
operator-env-configgap 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 deferredNEXT.md# App-secret injection hardening item, not this slice. - A failed config recreate doesn't self-converge a running container.
SetConfigis brain-commits-first (store + override hold the desired state, thencompose 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 matchesRebindMailand the documented "host is reconstructible" posture (CLAUDE.md); a rollback-on-failure was deliberately not added becausecompose upruns underhealthWaitand 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 onSetConfig/RebindMailwere 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.
- Re-import the env-config-blocked apps. With the mechanism in place, file Catalog app issues for the apps
operator-env-confignamed and author eachconfig: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_secretstogether (NEXT.md).