- Status: done
- Date: 2026-06-12
- Specs touched:
APP_ISOLATION.md,STORAGE.md
Closes #156, the shared-tree half of the bind-source preparation that prepare-every-bind-dir.md (#147) deliberately scoped out. #147 made the brain create + chown elected personal use-case folder sources before compose up, but left shared sources (/srv/malmo/shared/…) untouched because that tree has a different ownership model — so a household shared-folder app whose elected <Folder>[/<subfolder>] didn't pre-exist still got a docker-created root:root directory the malmo-app container couldn't write. This carves off that half plus the production ownership decision.
The issue flagged the ownership model as a spec call. STORAGE.md # Permissions settles it: the household shared tree is root:malmo-shared, mode 02770 (setgid), not chowned to a runtime UID — the issue's "presumably malmo-app:malmo-shared" guess is superseded by the spec. The malmo-app household identity (APP_ISOLATION.md: well-known 2000/2001) reaches the tree through its malmo-shared group_add, which writeOverride already emits; ownership is by group, not by UID. So a brain-created shared subfolder must set the group + setgid + group-rwx, never the owner.
Three sub-decisions:
- Where it runs — the brain, not a new host-agent op. Consistent with #147's personal loop and the private-bind-dir loop, both done in-brain: the production brain is root, already reaches
/srv/malmo, and already fetchesMalmoSharedGIDviaWellKnownIdentity. No new protocol surface. - Never re-own a parent.
os.MkdirAllcan't report which levels it created, so the prep walks the elected path one component at a time from the shared root down, applying the group + mode only to newly created levels. A pre-existing/srv/malmo/shared/Documentsis left exactly as the storage setup made it; the shared root's absence is a hard fault (not ours to create). - Dev seam — out of the inner loop (documented). Writing under
/srv/malmoneeds root, and the unprivileged native dev brain can't. The prep runs only under euid 0; under the dev brain it warns-and-skips, leavingmake devbehavior exactly as before (household shared-folder apps were already unsupported there). A dev-rooted shared base via the host seam — the symmetric mirror of #147's dev-rooted personal home — is the follow-up if dev parity is ever wanted.
- New
prepareSharedSource(root, src, sharedGID)creates each missing level beneath the shared root, owning it:malmo-shared(group only, owner left as the root brain) with mode02770. The mode is expressedos.ModeSetgid | 0o770, not a raw0o2000—os.Mkdir/os.Chmodtake anos.FileModewhere the setgid bit is a named flag, so a literal0o2770would silently not set setgid.Mkdir's mode is umask-masked, so the group + mode are reasserted with explicitChown/Chmod. - A second loop in
install(after the personal-source loop) runs it for eachsourceSharedmount, gated on euid 0 with rollback on failure (prod posture); euid ≠ 0 warns and skips (dev posture). Same shape as #147's personal loop. - The shared tree root became a
Manager.sharedRootfield (defaultSharedRoot = /srv/malmo/shared), threaded intoisolation.sharedBaseand used by bothhostSource(the bind path) and the prep. This is what makes the tests hermetic — they root it under a temp dir, so even a root-run suite never touches the real/srv(the personal side gets this for free via the fake host's temphomeRoot).
TestPrepareSharedSource_*(4 tests, 95.2% coverage of the helper, no root needed): creation sets02770+ setgid + the malmo-shared group on every new level; idempotent re-run is a no-op; a pre-existing parent is never re-owned while the missing leaf still gets the shared mode; and the reject paths (source outside the root, absent/non-dir root, a file as a path component, mkdir under a read-only parent, chgrp to a non-member gid). The non-root chgrp/mkdir-failure cases self-skip under euid 0.TestInstallFolders_SharedSkippedUnderUnprivilegedBrainpins the dev-seam decision: a household shared install under the unprivileged brain doesn't hard-fail and creates nothing under the shared root, while the override still binds the source the root brain would have prepared.- Existing
TestInstallFolders_HouseholdSharedWrite/TestInstallCustomFolders_TargetDestinationupdated to assert the bind under the (temp)sharedRoot. make checkgreen (gofmt, vet, OpenAPI freshness — no brain API surface changed — full Go suite).
- The production euid-0 create branch needs root to exercise end-to-end. Like #147's prod chown branch, the install loop's euid-0 path is left to the prod posture; the creation logic is covered directly by
TestPrepareSharedSource_*against a temp root with the test process's own GID. The real "boots clean +statshowsroot:malmo-shared02770" check is an on-hardware / rooted-VM step, not a hermetic unit test. - Household shared-folder apps remain out of the
make devinner loop (the dev brain is unprivileged). Documented above and inAPP_ISOLATION.md; the dev-rooted shared base is the follow-up. - No catalog app elects a shared
pick-subfoldersource yet, so the path is exercised by tests and hand-written manifests only. - Existing broken instances are not retroactively healed — install-time fix; a household shared-folder app installed before this with a root-owned source needs a reinstall (same caveat #147 carried).
- A dev-rooted shared base via the host-agent seam (mirror of #147's
devIdentityhome) if household shared-folder apps should work undermake dev. - The first catalog app that elects a shared
pick-subfoldersource, to exercise the path end-to-end on hardware.