3bea46f said running a container was unverified and the ACL fix unproven. Both are now
verified on a real member account: the container that previously died copying xattrs starts,
which means volume creation gets past system.posix_acl_default.
Documented in docs/per-user-linux-accounts.md rather than left in a commit message — why the
docker group is root and not an option, the host prerequisites, why linger is required, why
the setup tool's exit code cannot be the gate, and the ACL collision between the file
browser's default ACLs and Docker's volume creation.
Also written down because it bit within a minute of the feature working: a rootless daemon
is isolated but the HOST port space is not. RootlessKit publishes into it, so a member
mapping 5432 collides with the owner's production Postgres. Publish on 127.0.0.1 explicitly
— a bare -p binds 0.0.0.0 in rootless mode, which puts a member's dev database on the
network. Nothing allocates ports; with one member that is the owner's job by hand, and that
is where it stands deliberately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
408 lines
24 KiB
Markdown
408 lines
24 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` capability — terminal, chat, files, tasks, items, desktop, browser — runs as the
|
|
**owner's OS user in the owner's home**. That is why `capabilities/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 capability 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 `capabilities/` (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 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.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.** `claude-manager.ts` drives turns through `query()` from
|
|
`@anthropic-ai/claude-agent-sdk`, which spawns `claude` itself and takes `env`/`cwd` but has nowhere to
|
|
put a uid. Dropping privileges has to happen *outside* the SDK, which makes a member's turn its own
|
|
process — a change of shape rather than a flag. The credential is not the problem:
|
|
`officer-anthropic-proxy` already holds it, so a member's `claude` needs only `ANTHROPIC_BASE_URL`
|
|
pointed at the proxy and no key of its own.
|
|
- **`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 with `OFFICER_OS_USERS` on while any `.env` in the project root is group- or world-readable.
|
|
|
|
**`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. Nothing does today.
|
|
|
|
## 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` capability reopened.
|
|
5. **Agents.** Separately, later, with the SDK problem solved first.
|