Files
platform/docs/per-user-linux-accounts.md
T
pastilhasandClaude Opus 5 004f702747 no rotation needed: nobody else has ever used this install
the owner confirmed the additional accounts are test rows - none has ever
signed in, no member has opened a shell or a chat. so the proxy secret was
readable by accounts that do not exist in practice, and there is nothing to
rotate.

the mode fix stands: it is about the install that gets used, not this one.
dropping the TODO and recording the reasoning instead, because a rotation item
left sitting there reads as outstanding work forever, and the next person to
find a world-readable secret should ask who uses the box before reaching for
the alarm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:24:05 +00:00

503 lines
31 KiB
Markdown

# Per-user Linux accounts
**Status: in progress.** Stage 1 (the account and the privilege-drop mechanism) is being built now.
Agents are explicitly out of scope for the first pass.
## What this is for
Today every `execution` permission — terminal, chat, files, tasks, items, desktop, browser — runs as the
**owner's OS user in the owner's home**. That is why `permissions/registry.ts` declares them
`kind: 'execution'` and why `authorize.ts` strips them from a grant even if a row somehow contains one.
The registry says so out loud: _"revisit only if per-user home confinement is ever solved — and that is a
project, not a checkbox."_
This is that project. A member gets a real Linux account whose home is the directory the platform already
provisions for them, and the surfaces that execute code run **as that account**. The payoff is three
things at once:
- **Isolation** — a member cannot read another member's files, because the kernel says so rather than
because a path check happened to be right.
- **Permissions** — "may they see this" becomes a mode bit, checked by the OS on every syscall, instead
of a predicate the platform has to remember to apply on every route.
- **Separable agents** — `claude` and `opencode` run as the member, with their own `~/.claude`, their own
transcripts and their own session state, because the CLI groups by HOME and cwd.
## The target for the first test
A member signs in and:
- the **file browser** shows their home as the root and cannot navigate above it;
- the **terminal** lands in their home and has no permission to see anything above it.
Nothing else changes. Agents stay owner-only until this much is solid.
## The layout, and what each mode bit is for
```
data/ 711 service user traverse only — a member cannot enumerate the members
└── <email>/ 711 service user traverse only — a member cannot see their OWN siblings
├── home/ 700 the member their real Linux home
├── attachments/ 700 service user platform-written; unreachable even by name
├── email_accounts/ 700 service user "
├── dashboards/ 700 service user "
└── … 700 service user "
```
The important line is the second one. `data/<email>/` is traverse-only **to its own member**: they need
`x` to reach `home/`, and they must not have `r`, or they could list the platform's private tree beside
it. And because every sibling is `700 service user`, knowing a name does not help — traversal without
read gets you exactly one place, which is where they are going anyway.
This is also what resolves the two-sided ownership problem. The platform runs as the service user and
writes attachments, email databases and dashboards into `data/<email>/`; the member owns only `home/`.
Nobody needs a shared group, a setgid bit or an ACL, and neither side can write where the other lives.
**Members' homes stay under `DATA_PATH`** rather than moving to `/home/<user>`. They are the platform's
data, they belong with the rest of that account's data, and the directory is already provisioned there by
`provisionUserDirs`. A move would also break `getOwnerHomeDir`'s fallback, which is the only shape the
code has ever had for a non-owner home.
## Hard prerequisite: the secrets a shell can currently read
**This must be fixed before any member gets a shell, and it is not optional.**
On this machine, verified 2026-08-11:
| path | mode | consequence |
| ----------------- | ------- | ---------------------------------- |
| `/home/pastilhas` | 751 | traversable by anyone (no listing) |
| `…/officer.dev` | 775 | listable by anyone |
| `…/platform/.env` | **664** | **world-readable** |
`platform/.env` holds `POSTGRES_URL`, the JWT signing secret and every service credential. A member with
a real shell could read it and mint themselves an owner token, which makes the whole exercise worse than
not doing it — the permission model would be intact and completely bypassed.
So stage 1 includes: `chmod 600` on every `.env`, `chmod 751` on the project root so the tree is
traversable but not listable, and a **boot-time check that refuses to enable OS users while any `.env`
under the project root is group- or world-readable.** A prerequisite that is merely written down is a
prerequisite that gets skipped.
The same applies to `permissions/` (775 today) and to the repo checkout itself: a member can read the
platform source. That is acceptable — it is not secret — but anything credential-shaped inside it is not.
### The one this list missed: the Anthropic proxy secret
Found 2026-08-17, while verifying the claim that members do not share the owner's Claude Max subscription.
**They do not** — `spawnClaudeAsMember` runs their `claude` through `setpriv --reset-env` with a six-name
allowlist, so neither `ANTHROPIC_BASE_URL` nor `ANTHROPIC_API_KEY` can cross into it and their turns go
straight to Anthropic on their own OAuth. That part holds up.
But the isolation could be walked around:
| path | mode | consequence |
| -------------------------------- | ------- | ------------------------------------- |
| `data/sidecar/` | 775 | listable by anyone |
| `data/sidecar/claude-state.json` | **664** | **world-readable — holds the secret** |
`officer-anthropic-proxy` listens on `127.0.0.1`, accepts that string as `x-api-key`, swaps it for the
owner's Max OAuth bearer and forwards to `api.anthropic.com`. Every local account can reach loopback, so
the file IS the subscription. Confirmed by reading it as `nobody` — an account with no relationship to the
platform at all — and by observing the proxy 401 without the key, so the secret is the only control.
Nothing had ever set a mode: `mkdirSync`/`Bun.write` at Ubuntu's default umask of 002. The same umask
reasoning was already written down two sections above for `.env` and in `os-user.ts` for the owner's home;
this file was simply on nobody's list.
Fixed in two places, deliberately:
- **`state.ts` sets 0600/0700 on every write** — the authoritative fix, at the point that creates the file.
On every write rather than at creation, because `writeFileSync` leaves an existing file's mode alone.
- **`secureRuntimeSecrets()` sweeps at every boot** (`server.tsx`) — because the proxy is a separate PM2
process that can recreate its file long after Officer started, and because every existing install has the
old mode until something repairs it.
It **repairs** rather than refusing to boot, which is the opposite of `assertSecretsClosed` one section
above. The line is who created the file: `.env` is the installer's and Officer must not guess at it, while
this is Officer's own output at the machine's umask — a wrong mode there is Officer's bug, and bricking the
platform over it would punish the user for our mistake.
**No rotation was needed, and that is a fact about this install rather than about the bug.** A chmod closes
the door without changing the lock — anyone who read the secret while it was open still holds it, and it
does not expire. But the owner confirmed on 2026-08-17 that nothing here has ever been used by anyone else:
the additional accounts were created for testing, none has ever signed in, and no member has ever opened a
shell or a chat. So the window existed and nobody was standing in it.
Worth writing down because the code cannot tell you this, and the next person to find a world-readable
secret here will reach for the alarm before asking. **Ask who actually uses the install before deciding
how bad an exposure is.**
Should this ever need rotating on an install that IS used: clear `proxySecret`, then restart
`officer-anthropic-proxy` **and** `officer-claude-code` together — the agent sidecar caches the value in
`process.env.ANTHROPIC_API_KEY` and `ensureAnthropicEnv` returns early once it is set, so rotating the
proxy alone 401s every turn until the agent is restarted too.
## The mechanism, and the trap in it
### `Bun.spawn` silently ignores `uid` and `gid`
Verified on bun 1.3.10, 2026-08-11. From uid 1000:
```js
Bun.spawn(['id', '-u'], { uid: 65534, gid: 65534 }); // exit 0, prints "1000"
```
It does not throw. It does not warn. It accepts the option and runs as the parent. Every agent, task and
script spawn in this codebase goes through `Bun.spawn`.
Two honest qualifications, because the danger is narrower than it first looks:
- **Bun's own types do not declare `uid`**, so `bunx tsgo` rejects it. Typed code cannot reach this by
accident — confirmed while writing the test, which needs a cast to reproduce the behaviour at all.
- What _can_ reach it is a spread of untyped config, an `as any`, or a plain-JS sidecar. Two of the four
sidecars are `.mjs`.
So the exposure is real but bounded, and the mitigation is the same either way: privilege drops go through
an external wrapper, and a test pins Bun's runtime behaviour. If Bun ever implements the option, that test
fails and tells us we may simplify. **A silently absent isolation boundary is the worst possible outcome of
this project**, so it is worth a test that exists only to observe something staying broken.
### `sudo -n setpriv`, and why both words are needed
`runAs` builds:
```
sudo -n setpriv --reuid=<user> --regid=<user> --init-groups --reset-env -- <argv…>
```
- `--reuid`/`--regid` set the real ids, not just effective — there is nothing to switch back to.
- `--init-groups` applies the account's supplementary groups. Without it the process keeps the _owner's_
groups, which is a quiet way to retain access we just took away.
- `--reset-env` clears the inherited environment and then sets `HOME`, `SHELL`, `USER`, `LOGNAME` and
`PATH` from the target's passwd entry. Both halves matter: the parent's env contains the owner's `HOME`,
and on a process started by PM2 in the platform directory it contains everything Bun auto-loaded from
`.env`.
**`sudo` is not optional, and the reason is not the uid.** Measured 2026-08-11: `--init-groups` fails with
`initgroups failed: Operation not permitted` for an unprivileged caller _even when reuid'ing to its own
account_ — `setgroups(2)` is root-only, unconditionally. So there is no unprivileged form of this. `-n`
makes a missing sudoers entry an immediate error rather than a process hanging on a password prompt no
user will ever see.
Verified end to end, dropping to the current account:
```
$ sudo -n setpriv --reuid=pastilhas --regid=pastilhas --init-groups --reset-env -- \
sh -c 'id -u; id -G; echo HOME=$HOME; echo SECRET=${POSTGRES_URL:-unset}'
1000
1000 4 24 27 30 46 101 988 1001 ← supplementary groups from the account, not inherited
HOME=/home/pastilhas ← from passwd, after the reset
SECRET=unset ← the platform's .env did NOT cross
```
That last line is the whole security property, demonstrated rather than asserted, and it is pinned by a
test (`os-user.test.ts` → "does not pass the platform environment through").
`sudo -u <user>` alone would also work and be shorter. It is not used because its environment handling is
sudoers _policy_ — `env_reset`, `env_keep`, `always_set_home` — and "which variables cross into a member's
shell" must not depend on a config file someone may have edited.
Root is available: `scripts/setup/setup.sh` §4 installs `/etc/sudoers.d/officer-service` granting the service
user `NOPASSWD: ALL` on the full profile. The light profile deliberately skips it, so a light install that
wants OS users needs a **narrow** entry — `useradd`, `chown`, `setpriv` — which is better than the blanket
rule anyway.
### The terminal is the easy one
`sidecar/pty/sessions.mjs` uses **node-pty** under **node**, and node-pty's `spawn` genuinely honours
`uid`/`gid` (it is a native binding, not Bun's spawn). Two options; we take the second:
1. Run the pty sidecar as root and pass `uid`/`gid` per session.
2. Keep the sidecar unprivileged and make the command `setpriv … <shell> -i`.
(2) means no root daemon and one mechanism shared with everything else. A root daemon accepting session
requests over a socket is a bigger promise than this feature needs to make.
## Naming
**The username the owner chose, verbatim.** `whoami` in a member's terminal says who they are, their
prompt is their name, and a commit from their edge checkout is attributed to something recognisable.
This carried an `officer_` prefix for about an hour. The prefix bought three things — no collision with a
system account, a greppable record of what the feature created, and a member unable to pick a name that
shadows something real — and cost the only thing anyone would notice. Measured before removing it:
`useradd` on this host accepts everything `validateUsername` already permits, including dots, hyphens,
underscores and uppercase.
**What replaced the prefix's safety is the adoption rule, and it had to.** `ensureOsUser` reuses an
existing Linux account, which is what makes it re-runnable. That was safe by construction while only we
created `officer_*` names. With the name being whatever was typed, adoption became the dangerous path: a
platform account named `root` would have found root in passwd, and every `runAs` for that member would
have been a root shell. So an existing account is adopted **only when its passwd home is already exactly
the home we are about to confine** — that is what makes it ours — and any uid below 1000 is refused
outright as belt and braces.
Verified:
```
username "root" -> refused: 'root' is a system account on this machine.
username "daemon" -> refused: 'daemon' is a system account on this machine.
the owner's own account -> refused: 'pastilhas' is already a user on this machine, with its
home at /home/pastilhas. Refusing to take it over.
```
The resolved name is **stored** on the user row (`users.os_user`) rather than re-derived. `useradd` can
adjust or refuse a name, and re-deriving would mean the platform's idea of who a member is could drift
from what is actually in `/etc/passwd`.
## The ancestor trap
A member's home sits under `DATA_PATH`, which on a normal install sits under the **owner's** home — and
`/home/<owner>` is `750` on Debian and Ubuntu. Every mode bit on the account tree can be correct, the
directory can exist, and the member still cannot reach it, because they have no `x` on an ancestor four
levels up.
What that surfaced as, on the first real install:
```
ssh-keygen failed: Could not stat …/data/jg@pertento.ai/home/.ssh: Permission denied
```
Which points at exactly the wrong thing. `.ssh` was there and correctly owned; the account could not
traverse `/home/pastilhas`.
`firstUntraversableAncestor` now walks the chain **as the member** before anything tries to use the home,
and the error names the directory and the fix (`chmod o+x <dir>`). `x` without `r` is the ask throughout:
traversal, not listing — nobody gains the ability to enumerate the owner's home.
The development machine happened to be `751` already, which is exactly why the probe passed there and
failed on a fresh install. Worth remembering as a shape of mistake: the probe used `/tmp`, so it never
crossed the ancestor that mattered.
## Out of scope, and honest about it
- **This is not a sandbox.** A member with a shell is on the machine. They cannot read the owner's files
or another member's, and `sudo` is not theirs — but they can run code, see process names, and reach the
network. It isolates members from each other and from accidents, not from the host.
- **Agents come later** — but by less than this said. Two claims here were wrong and are corrected on
2026-08-11; the superseded text is in the git history of this file, and the working state is
`COMMS/sidecar-app-store/2026-08-11-per-user-claude-handoff.md`.
It said the SDK "has nowhere to put a uid", so dropping privileges had to happen _outside_ it, making a
member's turn its own process — "a change of shape rather than a flag". It is a flag: `sdk.d.ts:951`
exposes `spawnClaudeCodeProcess`, documented for running Claude Code "in VMs, containers, or remote
environments", and `node:child_process.spawn` already satisfies the `SpawnedProcess` shape it wants. So the
existing sidecar wraps the CLI spawn in `runAsArgv` per turn and there is no second process to stand up.
It also said the credential is not the problem because `officer-anthropic-proxy` already holds it, so a
member's `claude` "needs only `ANTHROPIC_BASE_URL` pointed at the proxy". That is backwards. The proxy holds
the **owner's** credential (`sidecar/claude/proxy.ts:7` reads the owner's own
`~/.claude/.credentials.json`), so pointing a member at it spends the owner's account on the member's
turns. Per-user Claude means their own login in their own home, and `setpriv --reset-env` is what makes that
the default rather than something to remember: nothing crosses into their process unless it is written into
the argv.
What does remain out of scope: **no platform process ever runs as a member.** The agent sidecar needs
`POSTGRES_URL` and the JWT signing secret, so a member-uid process holding them could read every account and
sign a token as the owner — more than their shell can do, and already refused by `assertSecretsClosed`. The
harness stays the service user's; only `claude` itself drops privileges.
- **`pty`, `vault` and `opencode` receive no identity at all** (`TODO.md` → Multi-user). pty keys purely
on a `sessionId` from the query string, and its `/_officer/sessions` endpoints list and kill _every_
session on the box. Safe today only because terminal is owner-only. **The moment a member has a shell
that is a cross-user kill switch**, so it is fixed in the same stage as the terminal, not after.
- **Email change orphans a home.** The on-disk layout is keyed on email everywhere. Renaming an account
would leave its home behind under the old address. Pre-existing, unfixed, worth knowing.
## What the first real run proved, and what it corrected
Stage 1 was exercised end to end against a throwaway `DATA_PATH` with a real `useradd`. Every property
below was **observed**, not reasoned about:
| attempted, as the member | result |
| ---------------------------------------- | ------------------------ |
| write in own home | OK |
| read `…/<email>/attachments/private.txt` | Permission denied |
| `ls …/<email>/` (their own account dir) | Permission denied |
| `ls $DATA_PATH` (enumerate the members) | Permission denied |
| `ls …/other-member@example.com/home` | Permission denied |
| `cd $HOME/..` | **succeeds** — see below |
Three bugs surfaced only by running it:
1. **`chmod` after `chown` fails forever.** `chmod` requires ownership, so once the home belongs to the
member the service user cannot set its mode. Both orderings fail unprivileged — the first on the second
run, the second immediately. Both operations now go through sudo, which is what makes the function
re-runnable.
2. **A member could read another member's home.** `provisionUserDirs` created directories at the default
umask (`755`), and the confinement pass only ever ran for the account being created. `DATA_PATH` being
unlistable is not protection when the child is world-readable and the attacker knows an email address.
The skeleton is now created closed — `711` on the account directory, `700` inside — so _unconfined_ is
also _unreachable_.
3. **`platform/.env` was readable, and printing `JWT_SECRET` from a member's shell was confirmed.** This is
the prerequisite above, demonstrated. It is now a boot check (`assertSecretsClosed`) that refuses to
start while any `.env` in the project root is group- or world-readable.
That check was itself conditional on `OFFICER_OS_USERS` until 2026-08-12, which meant the guarantee was
opt-in. The flag is gone and the check is unconditional: a security prerequisite that only holds when
somebody remembers to set a variable is not a prerequisite. Per-user Linux accounts are now simply what
the platform does, so there is nothing to enable and nothing to forget.
**`cd $HOME/..` succeeding is correct and worth being precise about.** `711` grants traversal, so `cd`
works while `ls` does not — they can stand in the directory and see nothing in it. Beyond that, a real
shell can reach `/etc`, `/usr` and anything else the system leaves world-readable, because that is what a
shell is. So:
- the **file browser** genuinely cannot go above the home — that is path containment in `resolveUserPath`,
enforced by the platform;
- the **terminal** cannot _read_ anything above the home, but is not confined to it. Confining it would
mean a namespace or a chroot, which is a different and much larger feature.
Say "cannot see behind it", not "cannot leave it".
## SSH: two keys, two directions
A member is meant to behave like a real user on the machine — reachable over SSH, able to push to Gitea as
themselves, able to have an agent do the same on their behalf. That needs two keys, and they are **not**
alternatives:
| | where | who holds the private half | what it is for |
| ------------ | ------------------------ | ---------------------------- | -------------------------------------------- |
| **inbound** | `~/.ssh/authorized_keys` | the member, on their laptop | _they_ SSH into this machine |
| **outbound** | `~/.ssh/id_ed25519` | this machine, generated here | _the machine_ authenticates to Gitea as them |
The tempting simplification is "if they pasted a key, skip generating one." It breaks the actual goal.
Agent forwarding covers a human in an interactive session; a **platform-spawned agent has no agent socket
to borrow**, so an edge checkout it is asked to commit and push needs a key that lives on the box. So the
inbound key is optional — an account without one is simply platform-only — and the outbound keypair is
generated regardless.
**No Linux password, ever.** `useradd` is called with none, which leaves `!` in shadow. That blocks
_password_ login and does **not** block key auth, so "real user, reachable over SSH, no password anywhere"
is the resting state. The privilege drop is `sudo -n setpriv` performed by the platform, so there is nothing
to authenticate. Keeping the platform password and the machine out of each other's business is the point: a
Linux password would be a second door that changing the platform password does not close and deleting the
platform account does not lock.
**Validation is about line count, not key shape.** Every line of `authorized_keys` is a credential, so a
pasted value containing a newline would silently install a _second_ authorized key. `validatePublicKey`
refuses anything multi-line, refuses a private key with a message saying so, and refuses an options prefix
(`command="…" ssh-ed25519 …`) — legitimate OpenSSH, but not something anyone pastes by accident, and it can
force a command.
**Everything is written with `sudo install`.** The home is 700 and owned by the member, so the service user
cannot create `.ssh` at all. `install` sets content, owner and mode in one step, which also closes the
window where a key file briefly exists at the process umask. File content goes via a temp path rather than
shell text, so nothing has to reason about quoting a value that came from a form.
**`StrictHostKeyChecking accept-new`, not a seeded `known_hosts`.** The Gitea SSH endpoint is not knowable
at account-creation time — the platform stores an HTTP base URL, and SSH may be a different host or port.
The failure this avoids is specific: the default setting makes a first connection _prompt_, and a prompt in
a non-interactive agent turn is a hang, not an error. `accept-new` trusts on first use and still refuses a
_changed_ host key, which is the attack that matters.
**The generated public key is stored on the user row** (`users.os_ssh_public_key`) and shown after creation
and on the user's row afterwards. It is public by definition, and it has an errand attached that nothing
else will remind anyone about: it has to be added to that person's Gitea account or their pushes fail with
a permission error that says nothing about a missing key.
Verified end to end with a real `useradd`: `.ssh` 700 and `id_ed25519` 600 both owned by the member and
readable by them, `authorized_keys` byte-identical to what was pasted, the key **not** rotated on a second
run (it has been added to Gitea by then), and a multi-line paste refused with `authorized_keys` left
untouched.
## Docker: rootless, one daemon per member
Verified working on a real member account, 2026-08-11.
**Not the `docker` group.** `usermod -aG docker <user>` is the one-line version and it is root: membership
means talking to the host daemon, which runs as root, so `docker run -v /:/host -it alpine chroot /host` is
a root shell. That reads `.env`, every other member's home and the wallet seed — every boundary above,
bypassed by one documented command. The group is not "access to Docker", it is "root, by a longer route".
Rootless gives what was actually wanted: a daemon per account, containers in that account's user namespace,
images under their own home. Measured — the daemon runs as the member, `docker pull` put 403 MB in their
home, and `docker ps -a` showed nothing while the owner had four containers running.
**Host prerequisites**, all in `setup.sh` as core packages: `uidmap` (newuidmap/newgidmap — rootless cannot
start without them), `dbus-user-session`, and Docker's own rootless extras. `useradd` allocates the
`/etc/subuid` range automatically wherever `login.defs` sets `SUB_UID_COUNT`, and `userdel` reclaims it.
**`loginctl enable-linger` is required, not optional.** Officer's shells are not login sessions, so without
it a member's daemon would stop the moment their terminal closed.
**The setup tool's exit code is not the gate.** It writes `~/.config/systemd/user/docker.service` and then
fails its own `systemctl --user start` with "Unit docker.service not found", because nothing reloaded a
manager that was already running. So: run it, `daemon-reload`, start it ourselves, and verify by asking the
daemon its version.
**Two features built the same day collided.** Creating a volume copies xattrs, and the DEFAULT ACLs on a
member's home — added so the file browser could read their files — are inherited by Docker's storage, where
a mapped id inside a user namespace is not a valid id to set:
```
failed to copy xattrs: failed to set xattr "system.posix_acl_default" on …/volumes/…/_data: invalid argument
```
Every container failed to start while the image pulled perfectly. The fix strips DEFAULT ACLs from
`~/.local/share/docker` only (`setfacl -R -k`), leaving the access ACLs the file browser depends on. Losing
the platform's reach into Docker's internal storage costs nothing: it is layers and volume data, read
through `docker` or not at all.
### The port space is shared, and that is not fixed
A rootless daemon is isolated; the **host's port space is not**. RootlessKit publishes into it, so a member
mapping `5432` collides with the owner's production Postgres — observed immediately:
```
error while calling RootlessKit PortManager.AddPort(): listen tcp4 0.0.0.0:5432: bind: address already in use
```
Two consequences worth knowing:
- **Publish on `127.0.0.1` explicitly.** A bare `-p 15432:5432` binds `0.0.0.0` in rootless mode, putting a
member's dev database on the network. `127.0.0.1:15432:5432` is all they need to reach it from their own
shell.
- **Nothing allocates ports.** With one member the owner manages it by hand, which is where this stands
deliberately. With several, a per-member offset is the crude answer that works.
## Follow-ups this creates
- **Deleting a member no longer removes their home.** It belongs to their uid, so the platform cannot
remove it — `rm -rf` fails with EPERM, which is how it was noticed. `deleteUserHandler` does not touch
disk today so nothing is broken, but account deletion will need `userdel` and a sudo `rm` to stop
leaving an orphaned, unremovable directory behind.
- **`confineUserTree` sets `DATA_PATH` itself to 711.** If a container ever bind-mounts a path under
`DATA_PATH` and runs as another uid, it will traverse but not list.
**This happened, and it was worse than the note predicted.** Reported from a live server: a bind-mounted
`postgres:18-alpine` crash-looped with `mkdir: can't create directory '…/18/docker'` on a directory that
already existed. Two reasons the prediction was too mild. The image's inner uid is 70, which maps through
the member's subuid range to 231141 — neither the service user nor the member, so `other`. And by then the
home carried `default:other::---` from the ACL work, so `other` had lost even the traverse bit that the 711
reasoning assumed. Traverse-but-not-list became no-traverse-at-all.
`3bea46f`'s fix — stripping defaults from `~/.local/share/docker` — covered NAMED VOLUMES only. A bind
source lives wherever the member put it. A named volume passes with the bug present, which is exactly why
that fix looked complete.
Now: `~/.local/dockers` is provisioned at `711` with **all** ACLs removed (`setfacl -R -b`, not `-k`), and
is the documented place for compose bind mounts. `711` rather than `700` is the point — a container's inner
uid needs `x` to reach a bind source inside, and no ACL can grant what the mode denies. `-b` rather than
`-k` because `-k` left `mask::---` behind, so inherited named entries read as `rwx #effective:---`: an ACL
that says one thing and means another.
Bounded deliberately. A member bind-mounting from elsewhere in their home still hits the denial; this is
the place that works, not a guarantee about everywhere. The alternatives were worse — extending the strip
cannot work when the member chooses the path, and `d:other::--x` on the whole home loosens every directory
forever to fix one local case.
## Stages
1. **The account and the mechanism.** `users.os_user`; `ensureOsUser` (useradd + chown + the mode bits
above); `runAs`; the `.env` permission gate; tests including the Bun-ignores-uid pin. **No behaviour
change** — accounts are created and nothing uses them yet.
2. **`getOwnerHomeDir` honours its email argument.** It takes an email and throws it away whenever
`HOME_DIR` is set, which is always on a real install. Seven call sites; this one change repoints the
file browser, chat, tasks, agents and the VNC password file per account.
3. **The file browser**, rooted at the member's home. Containment already exists — `resolveUserPath` +
`isInside`, which has the `..`-escape fix in it — so this is a root-resolution change, not new
security code.
4. **The terminal**, via `setpriv`, plus pty identity. One `execution` permission reopened.
5. **Agents.** Separately, later, with the SDK problem solved first.