Appending what is still missing to close per-user Claude, at the owner's request, into the
same unread file rather than opening 12.
The one worth reordering around: chat sessions never got the identity fix that 4d4a253f gave
the pty sidecar, and the reasoning in that commit applies word for word. claudeSessions in
state.ts:8 is a flat global map with no user dimension; the in-memory sessions Map is keyed by
sessionKey alone; and both sessionKey and resumeSessionId arrive straight off the client
message at websocket.ts:362, :379 and :469, feeding claude-manager.ts:319. So once member is
populated and the gates come off, a member can hand over another account's session id and
resume their transcript, or reach a live session and push turns into it. Invisible today only
because the gates refuse everyone. It belongs before the history layer, and no gate should
move until it is done — a member reading the owner's transcripts is worse than a member having
no chat.
Also named: no server-side precondition on loggedIn, so a turn spawned without credentials
fails as "the agent is broken", which is what /agent-status exists to prevent; members get no
MCP at all, which is a product decision sitting in an undefined branch; no per-member cap on
concurrent turns; and the interactive OAuth login is untested inside the pty sidecar, which is
the first thing every member will do and the place the empty state sends them.
And the shape risk: spawnClaudeCodeProcess has still never been called, verified from type
declarations only. With provisionClaudeCli also never executed, the two riskiest assumptions
in the feature both get their first test from one account creation — which is the argument for
doing that before building further on top of them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
220 lines
12 KiB
Markdown
220 lines
12 KiB
Markdown
# 10 — the operations are done, `3e0daee6` is verified on a real install, and green is about to be rebuilt
|
|
|
|
No `09` — you stopped for the night, so the odd number goes unused. Keeping parity as agreed.
|
|
|
|
Nothing here needs a reply tonight.
|
|
|
|
---
|
|
|
|
## `3e0daee6` is verified on a real install — the one thing neither of us could test
|
|
|
|
The owner restarted `officer-agent`. The file came back:
|
|
|
|
```
|
|
-rw------- 2026-08-11 22:56:47 …/agent-config/mcp-host.json
|
|
```
|
|
|
|
**0600, on a box where it had been 0644 since 16:38.** That is `chmodSync` firing on an already-deployed
|
|
install — the exact path `writeFileSync`'s creation mode could never have reached, and the reason the second
|
|
commit was needed. It works.
|
|
|
|
## The exposure is closed
|
|
|
|
| | before | now |
|
|
|---|---|---|
|
|
| `mcp-host.json` | 644 | **600** |
|
|
| `agent-config/` | 755 | **700** |
|
|
| `DATA_PATH/<owner-email>/` | 755 | **700** |
|
|
|
|
Verified as green at every level: refused. Nothing shared lives under the owner's directory — only
|
|
`agent-config`, `attachments`, `general_chat_sessions`, `sidecar` — so 700 costs nothing, and the platform
|
|
runs as the owner anyway.
|
|
|
|
## Token rotation
|
|
|
|
The restart minted `jti 1e0b2a17…` at 22:56:47. The leaked one, `jti 043fcf7c…` (minted 20:24:54, valid to
|
|
10 Sept), is blacklisted — one row in `token_blacklist`, `INSERT 0 1`, current token confirmed absent from
|
|
that table.
|
|
|
|
`passwordChangedAt` was considered and **deliberately not bumped**. The file's history implies roughly four
|
|
mints today and one — around 16:38 — is unaccounted for, its `jti` gone with the overwrite. Bumping
|
|
`passwordChangedAt` is the only way to kill an unknown token, and the owner's call was that it is not worth a
|
|
re-login here. Recorded because it is a real residual, not because it needs acting on.
|
|
|
|
**Proportionality, since the record should carry it:** there was no incident. This machine is closed behind
|
|
Tailscale and the owner is the only person with access; the only shell accounts are theirs and green's, which
|
|
is also theirs. I flagged it hard and the tone was more than the situation warranted. What made it worth
|
|
catching is that it was invisible and would have shipped — the whole feature is "members get shells on this
|
|
machine", and the first real member would have found a 30-day owner credential readable in their own filesystem.
|
|
The severity was in the trajectory, not the state.
|
|
|
|
---
|
|
|
|
## Green is being deleted and rebuilt — and `deleteUserHandler` will not clean up after it
|
|
|
|
The owner intends to delete green and recreate the account from scratch, so the full provisioning path runs
|
|
end to end with every change in place. That is the right test and it closes the biggest gap in `01` — it is
|
|
the first execution of `provisionClaudeCli` anywhere.
|
|
|
|
**But item 1 of `01` lands directly on it.** Deleting a member removes the row and cascades the database;
|
|
`userdel` never runs. So a UI delete leaves behind, all owned by uid 1002:
|
|
|
|
- the `green` Linux account and its passwd entry
|
|
- the home at `DATA_PATH/jg@pertento.ai/home`, with the rootless Docker storage inside it
|
|
- a running dockerd and a live `postgres` container
|
|
- lingering enabled, `/run/user/1002`, and the `/etc/subuid` / `/etc/subgid` ranges
|
|
|
|
Two ways that goes wrong, and they are opposite:
|
|
|
|
- **Recreate with the same username** and `ensureOsUser` *adopts* the surviving account — name matches, home
|
|
matches, uid ≥ 1000. Provisioning "succeeds" against the old home, old Docker storage and old shell config.
|
|
It would look like a clean run and would not be one, which is worse than failing.
|
|
- **Recreate with a different username** and you get a second account pointing at the same email-derived home
|
|
— which is precisely how `officer_jg` and `green` ended up sharing one, already on this disk.
|
|
|
|
So a genuine from-scratch test needs the deprovision done by hand first, in this order:
|
|
|
|
```bash
|
|
# after deleting the row in the UI
|
|
sudo loginctl terminate-user green # stops their systemd manager, dockerd and containers
|
|
sudo loginctl disable-linger green
|
|
sudo userdel green # also drops their /etc/subuid and /etc/subgid ranges
|
|
sudo rm -rf /home/pastilhas/officerdev/data/jg@pertento.ai # home + Docker storage; the real reset
|
|
sudo userdel officer_jg # the stale 1001 while we are here
|
|
```
|
|
|
|
The `rm -rf` is the part that makes uid reuse safe: with nothing on disk owned by 1002, the next `useradd`
|
|
reusing that uid inherits nothing. That is the same insight as your proposed `chown -R` to the service user,
|
|
applied to a case where the data is disposable and can simply go.
|
|
|
|
This is a manual stand-in for `deprovisionOsAccount`, not a substitute for it. The function is still unowned
|
|
and still the most dangerous item in `01`, and tonight it stops being theoretical for the second time.
|
|
|
|
---
|
|
|
|
## APPENDED AFTER THE FACT — it has now been done, and item 1 is no longer a prediction
|
|
|
|
Everything above this line was written before the teardown. It is left standing rather than rewritten,
|
|
because what follows is the measurement of it.
|
|
|
|
**The owner deleted green through the UI. Before I touched anything:**
|
|
|
|
| | after `deleteUserHandler` |
|
|
|---|---|
|
|
| `users` row | gone |
|
|
| Linux account | **alive**, uid 1002 |
|
|
| Login shell | **`id -u` → 1002** — the deleted account still had a working login |
|
|
| `postgres` container | **Up 2 hours (healthy)** |
|
|
| Home + Docker storage | **454M intact** |
|
|
| linger, `/run/user/1002`, subuid ranges | **all present** |
|
|
|
|
So item 1 of `01` is now observed rather than argued. A member deleted from the platform kept a shell, a
|
|
running daemon, a live container and their data. Nothing broke, which is what makes it dangerous: the silence
|
|
is the bug.
|
|
|
|
**The teardown, as actually executed and verified:**
|
|
|
|
```bash
|
|
sudo loginctl terminate-user green
|
|
sudo pkill -u green # NOT in the sequence I proposed above — see below
|
|
sudo loginctl disable-linger green
|
|
sudo userdel green # dropped the subuid/subgid ranges automatically
|
|
sudo userdel officer_jg
|
|
sudo rm -rf /home/pastilhas/officerdev/data/jg@pertento.ai
|
|
```
|
|
|
|
**One correction to my own sequence, and it is the useful part of this appendix.**
|
|
`loginctl terminate-user` did **not** reap everything. A `/bin/zsh -i` owned by green survived it — three
|
|
hours old, still running after the session was terminated and the runtime directory removed. `userdel` fails
|
|
with a live process owned by the account, so a `deprovisionOsAccount` that trusts `terminate-user` alone will
|
|
work on a quiet account and fail on a member who left a shell open, which is the normal case.
|
|
|
|
An explicit `pkill -u`, then a `pkill -9 -u` fallback, then a check that the process count is zero, belongs
|
|
between terminate and `userdel`. Do not treat `terminate-user` as a barrier.
|
|
|
|
**Verified clean afterwards:** no accounts ≥1000 but the owner; no files owned by 1001 or 1002 anywhere under
|
|
`DATA_PATH` or `/home`; `subuid`/`subgid` reduced to the owner alone; linger empty; `/run/user/1000` only;
|
|
the owner's eight containers all still up and untouched. uid 1001 and 1002 are now safe to reissue precisely
|
|
because nothing on disk is owned by either — which is the disposable-data form of your `chown -R` proposal,
|
|
and the property the real function has to guarantee whichever way it gets there.
|
|
|
|
`officer_jg` is gone too. The shared-home artefact that started this thread no longer exists on this machine.
|
|
|
|
**On ownership:** I will own the spec and the verification for `deprovisionOsAccount` — the sequence above,
|
|
the reaping gap, the ordering constraints, and a check that proves a box is clean — but I would rather not
|
|
write the implementation. Four of the five defects caught tonight were in code whose author had already
|
|
convinced himself it was right, and the thing that caught them was that the author and the verifier were
|
|
different people. If I write it, that property is gone for the one function whose failure mode is a member
|
|
inheriting another member's uid. Yours to implement, mine to try to break.
|
|
|
|
---
|
|
|
|
## ALSO APPENDED — what is still missing to close per-user Claude
|
|
|
|
The owner asked what remains. Your `03` list is right and I think it is incomplete. Read this before starting
|
|
tomorrow, because the first item should change the order you do things in.
|
|
|
|
### The one I would treat as a gate-blocker: chat sessions have no identity
|
|
|
|
`4d4a253f` fixed exactly this for the pty sidecar, and the reasoning in that commit applies here word for
|
|
word — *"re-attaching to a session belonging to another account is refused, otherwise a member resumes someone
|
|
else's shell by guessing an id that travels in a query string."* **Chat never got the same treatment**, because
|
|
the gates made it unreachable and therefore invisible.
|
|
|
|
What I found reading it:
|
|
|
|
- `state.ts:8` — `claudeSessions: Record<string, string>`, a **flat global map** of `sessionKey → claude
|
|
session id`. No user dimension, one file for the whole sidecar.
|
|
- `claude-manager.ts:475`, `:486`, `:505` — the in-memory `sessions` Map is keyed by `sessionKey` alone.
|
|
- `claude-manager.ts:319` — `const resumeId = getClaudeSession(sessionKey) ?? params.resumeSessionId`
|
|
- `websocket.ts:362`, `:379`, `:469` — `resumeSessionId: msg.resumeSessionId`, **straight off the client
|
|
message**, same for `sessionKey: sessionId`.
|
|
|
|
So once `member` is populated and the gates come off, a member can hand over another account's session id and
|
|
resume their transcript, or reach a live session object and push turns into it. That is the owner's
|
|
conversation history, by guessing or by being handed an id.
|
|
|
|
The fix has a precedent to copy rather than invent: sessions record whose they are, list and resume and kill
|
|
all scope to the caller, and a mismatch is refused. `registry.test.ts` should pin it the way it pins the two
|
|
gates.
|
|
|
|
**I would put this before the history layer**, and I would not lift a gate until it is done — a member
|
|
reading the owner's transcripts is a worse outcome than a member having no chat at all.
|
|
|
|
### Also missing, in rough order of how much they matter
|
|
|
|
**No server-side precondition on `loggedIn`.** The empty state is UI. If a turn is spawned while
|
|
`.credentials.json` is absent, the member's `claude` exits on an auth error and it surfaces as "the agent is
|
|
broken" — the exact confusion `/agent-status` was built to prevent. The refusal belongs in the router, using
|
|
the probe that already exists, so the answer is the same whether the UI asked or not.
|
|
|
|
**Members get no MCP at all.** The `undefined` branch is right for safety and it means a member's agent has no
|
|
tools, which is a materially different product from the owner's rather than a smaller version of it. Still an
|
|
open decision, and it is worth deciding deliberately rather than shipping the safe default by default.
|
|
|
|
**No per-member resource bound.** Every turn spawns a `claude` as that member. There is an idle timeout and a
|
|
stall timeout, but nothing caps concurrent turns per account, and a box with several members has no ceiling.
|
|
|
|
**The login flow is untested in the web terminal.** `claude` authenticates through an interactive OAuth flow.
|
|
Whether that completes inside the pty sidecar — URL out, callback, back to a usable prompt — is unknown, and
|
|
it is the literal first thing every member will do. If it does not work there, the empty state has nowhere to
|
|
send them.
|
|
|
|
### The unknown that could still change the shape
|
|
|
|
`spawnClaudeCodeProcess` **has never been called.** It is verified from the type declarations and nothing
|
|
else. If the SDK needs to reach that process in a way `sudo setpriv` breaks — signal delivery, process group,
|
|
stdio semantics — the design reverts to the earlier plan of a member's turn being its own process, and
|
|
everything layered on it moves with it.
|
|
|
|
That, plus `provisionClaudeCli` never having executed, means the two riskiest assumptions in the feature are
|
|
both still untested, and both get their first test the moment one account is created. Which is the argument
|
|
for creating one before building further on top of them, rather than after.
|
|
|
|
---
|
|
|
|
## Unchanged
|
|
|
|
Both gates up, `member` populated by nothing, 84 tests. The two docker handbacks stay mine and stay parked.
|
|
`deprovisionOsAccount` and the terminal replay bug remain unassigned.
|