Commit Graph
6 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 ed52faae02 correct two claims about agents that the SDK disproves
docs/per-user-linux-accounts.md carried the reasoning that per-user agents were
a large piece of work, and both halves of that reasoning were wrong.

The SDK does have somewhere to put a uid — spawnClaudeCodeProcess, documented
for running Claude Code in VMs and containers — so a member's turn does not have
to become its own process. And the credential claim was backwards: the proxy
holds the OWNER'S credential, reading the owner's own ~/.claude/.credentials.json,
so pointing a member at it spends the owner's account on the member's turns.
The previous handoff had already retracted that one; the doc had not caught up,
which is how a retracted claim stays live.

Corrected in place rather than deleted, with what was believed and why it was
wrong, because the superseded version is the interesting part: the first claim
is what made agents look like a later stage than they are.

Adds the constraint that actually is out of scope, which the old text never
stated: no platform process ever runs as a member, because the sidecar holds
POSTGRES_URL and the JWT secret.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:33:19 +00:00
pastilhasandClaude Opus 5 401dcb710c a blessed directory for container bind mounts
From a live-server report: a bind-mounted postgres:18-alpine crash-looped with
`mkdir: can't create directory '…/18/docker'` on a directory that already existed.

3bea46f stripped default ACLs from ~/.local/share/docker and I concluded the ACL problem
solved. It covered NAMED VOLUMES only. A bind source lives wherever the member put it, and
there the same collision returns by another route: the image's inner uid is 70, mapped
through the member's subuid range to 231141 — neither the service user nor the member, so
`other` — and the home carries default:other::--- from the file browser's ACLs. A named
volume passes with this bug present, which is exactly why the first fix looked complete.

~/.local/dockers is now provisioned as the documented place for compose bind mounts: mode
711, all ACLs removed. Two details that are the whole fix:

  711, not 700 — a container's inner uid is `other` and needs x to reach a bind source
  inside. No ACL can grant what the mode denies, and 700 blocks the path before any ACL is
  consulted. `r` stays off so nothing can list it, and the home above is still 700, so no
  other account can traverse this far anyway.

  setfacl -b, not -k — `-k` removes defaults but left mask::--- behind, so inherited named
  entries read as `user:pastilhas:rwx #effective:---`. An ACL that says one thing and means
  another is worse than none, and container storage wants ordinary mode bits.

Chosen over the alternatives: 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. Bounded deliberately — a bind mount from elsewhere in the home still hits the denial.
This is the place that works, not a promise about everywhere.

VERIFIED: the directory comes out `user::rwx group::--- other::--x` with no ACL and no
defaults, which is the design exactly.

NOT VERIFIED: a container actually starting from a bind mount in it. My host recycles uid
1001 across probe accounts and a stale /run/user/1001 — a systemd runtime mount that
survives rm — leaves the new account with no bus, so rootless Docker will not start here.
That is the deprovision/uid-reuse problem in the queue, hitting the test rig. The live
server is the place to confirm it: green is uid 1002 with no recycling, and the report that
prompted this came from there.

docs/per-user-linux-accounts.md line 337 predicted a milder version of this and said
"nothing does today". Corrected: something does, and the ACLs had removed the traverse bit
its 711 reasoning assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:54:39 +00:00
pastilhasandClaude Opus 5 aaeb3424ab the rootless docker fix is proven; correcting the record
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>
2026-08-11 20:20:49 +00:00
pastilhasandClaude Opus 5 ea0d2396f7 linux accounts use the chosen username, and refuse to take one over
Two changes, and the second is what makes the first safe.

The officer_ prefix is gone: a member's account is the username the owner typed, so
whoami says who they are and a commit from their checkout is attributed to something
recognisable. Measured first — useradd on this host accepts everything
validateUsername permits, including dots, hyphens, underscores and uppercase.

The prefix was also load-bearing, though, and not for looks. ensureOsUser REUSES an
existing account, which is what makes it re-runnable, and that was safe by
construction while only we created officer_* names. Unprefixed, adoption becomes 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 adoption now requires
the existing account's passwd home to be exactly the home we are about to confine —
that is what makes it ours — and any uid below 1000 is refused outright.

Verified: root and daemon refused as system accounts, and the owner's own username
refused by name with its real home quoted back.

Also, the ancestor trap from the first real install. A member's home is under
DATA_PATH, which is under the OWNER'S home, and /home/<owner> is 750 on Debian and
Ubuntu — so every mode bit on the account tree was right, the directory existed, and
the member still could not reach it for want of x four levels up. It surfaced as
"ssh-keygen: Could not stat …/.ssh: Permission denied", which points at the wrong
thing entirely. firstUntraversableAncestor now walks the chain as the member before
anything uses the home, and the error names the directory and the chmod.

The dev machine was already 751, and the probe used /tmp, so it never crossed the
ancestor that mattered. Worth remembering as a shape of mistake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:46:01 +00:00
pastilhasandClaude Opus 5 0fb9a29e64 ssh for a member's linux account, both directions
Inbound and outbound are two keys doing two jobs, and treating them as
alternatives breaks the goal:

  inbound   ~/.ssh/authorized_keys, from an optional public key the owner pastes
            on the create form. Their private half stays on their laptop.
  outbound  ~/.ssh/id_ed25519, generated in their home, never leaves the machine.

"They pasted a key, so skip generating one" is the obvious simplification. Agent
forwarding covers a human in an interactive session, but 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. The inbound key is therefore optional and the
outbound one is not.

No linux password, ever: useradd sets none, which blocks password login and does
not block key auth. So "real user, reachable over SSH, no password anywhere" is
the resting state, and the platform password stays the platform's business.

Validation is about line count, not key shape. Every line of authorized_keys is a
credential, so a pasted value with a newline would install a SECOND key silently.
Multi-line refused, a private key refused by name, an options prefix refused.

Every write goes through sudo install: the home is 700 and the member's, so the
service user cannot even create .ssh. install sets content, owner and mode in one
step, and content travels as a temp path so nothing quotes a form value into a
shell. ssh-keygen runs AS the member so the private key is never briefly root's.

known_hosts is not seeded — StrictHostKeyChecking accept-new instead. The Gitea
SSH endpoint is not knowable at create time, and the default setting makes a first
connection prompt, which in a non-interactive agent turn is a hang rather than an
error. accept-new still refuses a changed host key.

The generated public key is stored on the row and shown twice: on the after-create
panel and behind a key button on the user's row. It has an errand attached that
nothing else will remind anyone about — it must be added to their Gitea account.

Verified with a real useradd: .ssh 700 and id_ed25519 600 both owned by the member
and usable by them, authorized_keys byte-identical to the paste, no key rotation on
a second run, and a multi-line paste refused with authorized_keys untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:02:33 +00:00
pastilhasandClaude Opus 5 5c7ceb2283 per-user linux accounts, stage 1: the account and the privilege drop
A member gets a real Linux account whose home is the directory the platform already
provisions for them. Nothing uses it yet — this is the mechanism plus the account,
deliberately with no behaviour change, so the file browser and terminal can be moved
onto something already proven.

Bun.spawn silently ignores uid/gid. Verified on 1.3.10: from uid 1000,
Bun.spawn(['id','-u'], {uid: 65534}) exits 0 and prints 1000. No throw, no warning.
Bun's types don't declare the option so typed code can't reach it by accident, but the
runtime accepts it, and a silently absent isolation boundary is the worst outcome this
feature could have. So privilege drops go through sudo -n setpriv, and a test pins Bun's
behaviour — if it's ever implemented, that test tells us we may simplify.

sudo is required for the drop and not because of the uid: --init-groups fails with
"Operation not permitted" for an unprivileged caller even when reuid'ing to its own
account, because setgroups(2) is root-only. --reset-env is what stops the platform's
environment crossing; verified POSTGRES_URL is unset on the far side and HOME arrives
from the target's passwd entry.

Three bugs that only a real run with a real useradd could find:

- chmod after chown fails forever, because chmod needs ownership. Both orderings fail
  unprivileged. Both operations now go through sudo, which is what makes it re-runnable.
- a member could read ANOTHER member's home: provisionUserDirs created at the default
  umask (755) and only the account being created got confined. An unlistable parent is
  no protection when the child is world-readable and emails are guessable. The skeleton
  is now created closed, 711 on the account dir and 700 inside.
- platform/.env was 664 and a member's shell printed JWT_SECRET, which is enough to mint
  an owner token and bypass every capability check. Now a boot check that refuses to
  start with OFFICER_OS_USERS on while any .env in the project root is group- or
  world-readable.

Design, the measured results and the staging plan: docs/per-user-linux-accounts.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:38:32 +00:00