Commit Graph
983 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 4cebae1c85 schema barrel: core tables only, plugin tables commented
db:push now creates 24 tables instead of 43. The 19 belonging to sidecars a
light install does not run are commented out in schema/index.ts, kept as the
record of what each table is called and which file defines it.

Core is ecosystem.light plus officer-headscale, plus the app store and
service_connections — app-store/effects.ts reads the latter, so it is core
however few plugins exist. Commented: email, music, notify, dav, photos,
jellyfin, invoiceshelf, soulseek, vault, wallet.

The reason this is only a barrel edit is worth writing down. drizzle.config.ts
points `schema` at schema/index.ts, so that file is drizzle-kit's view of the
schema — but it is NOT the runtime's. Every query imports its table object from
a schema file and calls db.select().from(table), and nothing anywhere uses
drizzle's relational API (db.query.X), which is the only thing the `schema`
passed to drizzle() in db.ts is for. So a commented line removes a table from the
database without removing a line of code.

That only held after moving nine query modules off the barrel and onto their own
schema file — email-accounts, music, notify, photos, jellyfin, invoiceshelf,
soulseek, vault and wallet all imported their tables from '../schema', so
commenting the barrel would have broken the query module, then its export, then
its consumers. dav already did it the right way. With that done the cascade is
gone: everything still compiles, and the tables simply are not created.

Two corrections to what was asked, both checked rather than assumed. The query
barrel (src/index.ts) has no bearing on db:push — drizzle never reads it — so
commenting it would not have kept a single table out of the database. And vault
is not plugin-only from the platform's side: /api/vault is mounted top-level in
hono.ts for the Bitwarden client, and api/vault/router.ts imports getVaultTokens.
Its TABLES are out, but that route still exists and will fail against them.

Not typechecked — empty node_modules, frozen installs — and db:push was not run,
since there is no database here. Every file in the package parses, as does
everything in the tree importing officerdb, and nothing outside the package
reached a plugin table through the exported schema namespace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 01:24:26 +00:00
pastilhasandClaude Opus 5 8207824a81 build the secret store: one key per purpose, none in .env
.env now holds PORT and POSTGRES_URL. Every encryption and signing key lives in
$OFFICER_ROOT/secrets/officer-keys.db — 0600, 0700 directory, owned by the
service user, created on first use.

The design doc planned to move ONE at-rest key into the store. What shipped
splits it: headscale, wallet, photos, jellyfin, invoiceshelf, vault and
service-connections each get their own, plus jwt. VAULT_STORE_KEY encrypted all
seven, so one leak opened all of them — and it was named after whichever plugin
needed it first, which is why it read as safe to change if you did not run a
vault. A core install bootstraps two, jwt and headscale; the rest appear when
their plugin first asks.

The file IS the secret. No second key unlocks it, because a key beside the store
it opens buys nothing. The gain was never secrecy, it is blast radius: bun
auto-loads .env into all twenty pm2 processes, so a key there is readable from
/proc/<pid>/environ of twenty processes — officer-music held the key that
decrypts wallet seed envelopes.

Two defects found by testing the store rather than reading it, both of which
would have shipped:

  The WAL was 0644. Enabling WAL creates -wal and -shm at 0644 rather than
  inheriting the database's mode, and a freshly written key lives in the WAL
  before checkpoint — so the 0600 on the database was decorative. The 0700
  directory covered it, but only until someone loosened the directory.

  PRAGMA journal_mode = WAL takes an exclusive lock, and busy_timeout was set
  AFTER it. With twelve concurrent openers, six died on that line with
  SQLITE_BUSY. Every sidecar opens this store at boot, so they open it
  simultaneously by definition: most of them would have failed to start on a cold
  boot and none on a warm one. Fixed by ordering the pragmas; re-tested with
  twelve racing processes, one key, one row.

crypto.ts takes a purpose as its first argument now, which the design doc had
explicitly promised would not happen — 32 call sites across seven query modules.
That promise is corrected in the doc rather than quietly dropped.

Also live, not just comments: wallet/upstream.ts gated wallet storage on
process.env.VAULT_STORE_KEY and would have reported "unconfigured" forever. It
asks the store now, and the question it answers changed — not "did somebody set a
variable" but "can this process open the store", since the key is created on
demand.

assertSecretsClosed covers the store, its directory and its WAL. The jwt key
mints owner tokens, so a member's shell reading it is strictly worse than the
.env leak that check was written for.

Not typechecked: node_modules is empty and installs are frozen, so the
officerdb/secret-store subpath could not be resolved at runtime here — verified
that officerdb/types fails identically, so it is the empty tree and not the new
export. The store module itself was tested directly: creation, idempotence across
processes, hasKey not creating, permissions, and the twelve-way race. Every
changed file parses; the setup section runs and degrades correctly when the
import is unavailable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 01:00:12 +00:00
pastilhasandClaude Opus 5 9864fb1a49 switch off the browser relay, pending extraction into a plugin
BROWSER_RELAY_PORT is gone and the second listener no longer starts. The Chrome
extension, api/browser/ and the /browser screen all stay on disk — this is going
to be extracted, and deleting it means writing it again.

Three things had to move together, and the middle one would have failed the boot
on its own:

  server.tsx    the listener, commented out with the variable name recorded
  hono.ts       the /api/browser mount, closed
  registry.ts   the 'browser' capability's claim on /browser, dropped

assertCapabilityTotality checks both directions: check 2 refuses to start on a
capability claiming a prefix nothing serves. Unmounting the router alone would
have left the registry describing it, and the server would not have come up.

The capability itself survives because it also claims /scrape, which shares
nothing with the relay — it launches its own headless chromium through playwright
and never speaks to the extension.

The comment in server.tsx carries the two facts that are not recoverable by
reading the remaining code. First, the port is an INPUT TO A CREDENTIAL:
relay-auth.ts derives each extension's token as HMAC(JWT_SECRET,
'officer-browser-relay-v1:${port}:${userId}:${salt}'), so bringing the relay back
on a different number silently invalidates every paired browser — reported by the
extension as "Relay not reachable", which SETUP.md blames on a wrong address,
port or token. Second, it cannot come back as a kernel-assigned port:0 like the
other sidecars: the extension is configured by hand and stores the value, so a
port that moves each restart breaks the pairing each restart.

Left alone deliberately: the /browser route in App.tsx, its Dock entry, and the
Settings → Browser Relay panel. They will not work against a closed endpoint.
Removing them is frontend work for the extraction, not part of switching the
listener off.

.env is down to PORT and POSTGRES_URL.

Not typechecked (empty node_modules, frozen installs). Every changed file parses;
the setup section was run and writes two variables.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 00:32:29 +00:00
pastilhasandClaude Opus 5 571d0a62ff delete the bug-report discord webhook, and the last dead HOME_DIR reads
DISCORD_BUG_REPORT_WEBHOOK is gone, with sendToDiscord and its helpers. Reports
still land in DATA_PATH/bug-reports — the disk write always happened first and
the webhook was only a ping about it, so nothing about the report is lost.

It was a personal notification channel living in deployment config, on a platform
whose owner is the only person who files reports. It was also never in
.env.example: the setup script wrote a variable nothing documented, which is the
same drift as PORT, in the other direction.

Note DISCORD_WEBHOOK_URL is a DIFFERENT variable — the notify sidecar's own
channel — and is untouched.

Then a parity sweep of setup / .env.example / what the code reads, which turned
up two leftovers from earlier today:

HOME_DIR was still read in six files, each with its own `?? homedir()` fallback.
Dead since nothing sets it, but a dead read is worse than none — it reads as a
supported override. They take homedir() directly now. user-instance.ts gets a
comment on why its line stays where it is: it sits above `process.env.HOME =
homeDir`, and homedir() reads $HOME, so a read moved below that assignment would
return whichever member was last spawned into. Two of the six had fallback chains
ending in process.cwd() and '' — the second would have silently disabled whatever
consumed it rather than failing.

VAULTWARDEN_URL was uncommented in .env.example among the variables setup writes,
though it is a plugin variable setup has never written. Commented out with the
other plugin entries.

The three files now agree: setup writes PORT, BROWSER_RELAY_PORT and
POSTGRES_URL; .env.example lists those plus JWT_SECRET and VAULT_STORE_KEY, which
are required by code and deliberately unwritten until the secret store lands.

Not typechecked (empty node_modules, frozen installs). Every changed file parses;
the setup section was run and writes three variables.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 00:23:32 +00:00
pastilhasandClaude Opus 5 f063fc0c08 remove origin validation
ALLOW_ANY_ORIGIN, ALLOW_ANY_ORIGIN_MUSIC, and everything they gated. The flag
defaulted to ON, so none of it ran on a real install — what comes out is
documented defence in depth that was already switched off. The file said so
itself: "Both flags and their call sites come out once the tailnet is the
perimeter."

Origin was never authentication here in any case. An app's `officer://<hex>`
origin is chosen by the client, forgeable outside a browser, and extractable from
a shipped binary.

Gone: the two flags, isOriginAllowed, isOriginCheckDisabled, isMusicOriginExempt,
originValidationMiddleware, ORIGIN_RULES and the whole OFFICER_<APP>_ORIGIN
scheme, PUBLIC_URL's origin/host derivation, and origin-validation.test.ts, which
existed only to pin them. CORS now echoes whatever Origin it is given, which is
what every install already did.

What SURVIVES is the reason this needed care. origin-validation.ts held two
unrelated things, and the second was the global authorization gate — a valid
non-owner token reaches only what its role grants, deliberately NOT under the
flag because it is account-based rather than origin-based. Its own comment called
it "the airtight half". Deleting the file wholesale would have deleted
authorization.

So it moves to _middlewares/capability-gate.ts as capabilityGateMiddleware, with
the name matching what it does: nothing in it reads an Origin header any more.
hono.ts mounts it in the same position, ahead of every router.

origin-middleware.ts stays and is untouched — it extracts the Origin for six auth
handlers that log it, and for passkeys. Extraction, not validation.

Also updates every claim that rested on the old model: CLAUDE.md's security
section and repo map, docs/secret-store.md, docs/mobile-api-keys.md, and five
messages in machine-setup's Tailscale section which told the owner to set
ALLOW_ANY_ORIGIN=false when declining a tailnet. That advice is now impossible to
follow, and the honest version is different: with no tailnet the token is the
whole lock, so put a proxy in front and restrict who can reach it.

Not typechecked (empty node_modules, frozen installs). Every changed file parses;
the setup section was run and writes four variables now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 00:15:25 +00:00
pastilhasandClaude Opus 5 c5adb4aa08 the anthropic proxy binds PORT + 1
ANTHROPIC_PROXY_PORT is gone. It was 5051 hardcoded in four files: proxy.ts,
which binds it, and three others that guessed the same constant to find it.

It is the only sidecar that binds a fixed port, and that part is a real
constraint rather than an oversight. Every other one binds `port: 0`, lets the
kernel choose and reports back over the registration socket — which works because
their consumer is the platform. The proxy's consumer is `claude`, spawned by a
different pm2 process that needs ANTHROPIC_BASE_URL at spawn time and has no
channel to ask what port the proxy landed on. Two processes with nothing between
them have to agree in advance.

So the number must be predictable, but it need not be 5051 — a value chosen
against nothing, in the registered range, free to collide with anything the owner
installs later. The symptom of that collision would have been chat failing while
the rest of the platform looked healthy.

PORT + 1 keeps the predictability and drops both the constant and the variable.
Nothing to set, no second number to keep in agreement with the first, and the
pair moves together when the install moves.

Also corrects .env.example, which said the proxy "holds the API credential, which
lives in the host env". It does not. The upstream credential is the OAuth token
claude writes to ~/.claude/.credentials.json, and the ANTHROPIC_API_KEY the agent
presents is the proxy's own generated secret.

Verified the derivation at PORT=9000 and PORT=10000; all four consumers now import
it; every edited file parses. Still not typechecked — empty node_modules, frozen
installs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:51:03 +00:00
pastilhasandClaude Opus 5 3c7f52ab77 PORT is read in one place, and it has no default
officer-url.mjs is now the only file in the tree that touches process.env.PORT.
Twenty-two others read it and supplied their own default; a value with
twenty-two sources is not configuration, it is twenty-two things to keep in sync,
and they had already drifted three ways.

It throws when PORT is unset rather than guessing. A default only covers the case
where .env was never loaded — which is not a machine anyone wants running,
because POSTGRES_URL is missing in the same breath. What the default bought was a
process that starts, binds somewhere unexpected, and fails later for a reason
that does not name the cause. Same posture as jwt.ts with JWT_SECRET.

It is .mjs, not .ts, and that is the whole reason this could be one file. pm2
launches officer-pty with node (ecosystem.config.cjs) and everything else with
bun; node cannot import TypeScript, so a .ts module would have left the pty
sidecar holding the only surviving copy of the default — precisely the thing
being removed. allowJs is already on, so the TS callers still get types. Verified
both runtimes import it, and that PUBLIC_URL-style overrides still work.

It also exports API_URL and OFFICER_API_URL, because nineteen sidecars were
independently building `ws://127.0.0.1:${PORT}` and two more were building the
http form. Those are one listener described in two protocols — no sidecar binds
anything — so they belong beside the port rather than being rediscovered per
file.

server.tsx now takes PORT as a number, so Number(PORT) at the serve site is gone.

Not typechecked (empty node_modules, frozen installs). Every edited file parses
under `bun build --no-bundle`; node and bun both load the new module; the unset
and non-numeric paths were exercised; the pm2 profile still loads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:43:39 +00:00
pastilhasandClaude Opus 5 e72cae4830 one default port, and it is 9000
Every PORT fallback in the tree now says 9000. There were three answers to one
question, each defensible where it was written and none of them visible from the
others:

  server.tsx and 19 sidecars   5000    a default from before there was an installer
  user-instance.ts             9010    what scripts/setup-old/setup.sh really wrote
  .env.example                 9000    what we told people to write

5000 goes first because macOS binds it — AirPlay Receiver has owned it since
Monterey, so a dev server there fails to bind or gets shadowed by something that
answers.

All 22 sites moved together, which is the point. Changing the app alone would have
turned a consistent-but-wrong default into a split one: the app on 9000 while
nineteen sidecars still dialled 5000.

9010 was the interesting one. It was the only value that ever matched a real
machine, because it is what the old installer wrote — and it was in the single
file whose disagreement would have broken chat alone, with nothing else looking
wrong. Its own comment records the same bug being fixed once already, within the
file, by a change that left it disagreeing with everything outside it.

Note what these defaults actually are: the sidecars bind nothing. user-instance.ts
has no listener at all — it builds ws:// and http:// URLs that both address the
app's single listener. So every one of these numbers is a guess at where the app
is, for a value that .env always supplies. Worth removing rather than aligning,
which is a separate change.

Not typechecked (empty node_modules, frozen installs). Every edited file parses
under `bun build --no-bundle`; the pm2 profile loads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:38:15 +00:00
pastilhasandClaude Opus 5 3bc06bba3c spell the root derivation as resolve rather than dirname
resolve(process.cwd(), '..') instead of dirname(process.cwd()). Identical on
every input — checked including trailing slash and filesystem root — and it reads
as the path arithmetic it is. resolve was already imported here for SEED_PATH.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:23:10 +00:00
pastilhasandClaude Opus 5 3f071c0b24 the install root is derived, not configured
Seven variables out of .env. DATA_PATH, OFFICER_ITEMS_DIR and HOME_DIR are gone
from the code entirely; PUBLIC_URL, PUBLIC_BUILD_ENV, JWT_SECRET and
VAULT_STORE_KEY are no longer written by the setup script.

data-path.ts now derives OFFICER_ROOT as dirname(process.cwd()), with data/,
capabilities/ and dockers/ as fixed names under it. The direction used to run the
other way — DATA_PATH from env, then OFFICER_ROOT = dirname(DATA_PATH) in
app-store/paths.ts — which meant three environment variables that had to agree
with each other and with the tree on disk.

Eight files re-read process.env.DATA_PATH independently, each with its own
`?? cwd()/data` fallback. They import the one value now, which is what made
removing it safe: otherwise each would have derived its own and drifted.

Three things this turned up.

The cwd pin in ecosystem.profile.cjs was broken. It set `cwd: __dirname` under a
comment asserting "__dirname is the repo root — this file sits beside
ecosystem.config.cjs", which stopped being true when these files moved into
ecosystem-files/. It walks up to the platform's package.json now, which holds
wherever the file lives. That was a live bug before this change and a load-bearing
one after it, since cwd now decides where the install is.

assertInstallLayout joins the other two boot assertions. A wrong cwd does not
error — it computes a plausible root somewhere else and writes managed homes and
agent runs into it, so the install looks empty and the data looks lost with
nothing naming the cause. It throws before serve(), first of the three, because a
wrong answer there makes the other two check the wrong files.

getOwnerHomeDir captures homedir() once at module load rather than per call.
Measured on bun 1.3.10: both os.homedir() and os.userInfo().homedir return $HOME
when set rather than reading passwd, and user-instance.ts assigns process.env.HOME
on its way to spawning an agent. A lazy read would have returned the owner's home
on the first call and a member's afterwards. data-path.ts imports only node
builtins, so it is evaluated before any of that runs.

JWT_SECRET and VAULT_STORE_KEY leaving .env means an install made by this script
does not boot — jwt.ts throws at module load without one. That is the agreed
sequencing: they move to the SQLite store (docs/secret-store.md), and writing them
here meanwhile would create a second origin for a secret the store then has to be
reconciled with. Said plainly in .env.example and in lib/env.sh rather than left
to be discovered.

Not typechecked: node_modules is empty here and installs are frozen. Every edited
file parses under `bun build --no-bundle`; the profile loads and pins the right
cwd; assertInstallLayout was exercised from both the repo and /tmp; the setup
section was run and writes five variables. Prettier was NOT run — 3.9.6 via bunx
is not the pinned resolution and reformatted unrelated unions and line wraps in
six files, so those were reverted and the edits re-applied by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:18:48 +00:00
pastilhasandClaude Opus 5 86079adb9a mail transport is configured in the app, not in .env
MAIL_TRANSPORT was a fallback left from the old registration flow that sent
confirmation mail. That flow is gone; the variable outlived it.

It was never the primary source anyway. getTransport reads server_config
('server-settings' → smtp) first, which already backs a full UI at Settings →
Server → SMTP and its API in api/server-settings/smtp.ts, supporting resend,
smtp and mailhog. The env var only answered when that was absent — which is a
second source of truth for something the owner can already set, with the failure
mode that a stale URL in .env silently answers for a server whose settings row
is simply empty.

Removed from transport.ts, .env.example and the setup script's Environment
section, which no longer asks for it. setup-old/ still mentions it; that is the
archive and is left alone.

Also split the try. It wrapped the read AND the transport construction and
swallowed both, so three different problems produced one message. Unreachable
database, nothing configured, and stored settings that do not build a transport
now say different things, because the fix for each is different and this message
is all the caller ever sees.

The two consumers — queue/engine.ts and auth/forgot-password.ts — now raise
until SMTP is set in the UI, which is the honest answer rather than a regression.

Not typechecked: node_modules is empty here and installs are frozen. transport.ts
parses under `bun build --no-bundle`; the setup script was run and no longer
prompts for or writes the variable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:01:17 +00:00
pastilhasandClaude Opus 5 040ea41dbc per-user linux accounts are not optional any more
OFFICER_OS_USERS is gone. The platform behaves as it always would have with the
flag on, and there is nothing to enable.

Six conditionals, five of which were dead weight — provisionOsAccount,
deprovisionOsAccount and the create/delete paths each opened with an early
"not enabled on this server" return, and the API told the frontend whether to
render the Linux controls at all. Those go, along with the 'disabled'
DeprovisionResult stage, which nothing can produce now.

The sixth is the one with teeth. assertSecretsClosed opened with
`if (!OS_USERS_ENABLED) return`, described in its own comment as "a no-op when
the feature is off, so an existing install is unaffected until the owner opts
in". It is now unconditional: the server refuses to boot while any .env in the
project root is group- or world-readable. A member's shell reading .env and
printing JWT_SECRET was confirmed exploitable when this check was written, and a
prerequisite that only holds when somebody remembers to set a variable is not a
prerequisite.

Nothing to remove on the environment side — the flag was never in .env.example
or in the setup script.

Not typechecked: node_modules is empty in this tree and installs are frozen, so
tsgo could not run. All six files parse under `bun build --no-bundle`, and the
changes are deletions of dead branches plus one removed early return. Formatted
with prettier 3.9.6 via bunx rather than the pinned resolution, for the same
reason; its one unrelated reformat was reverted by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 22:58:35 +00:00
pastilhasandClaude Opus 5 a64d5610e6 officer-setup section 5: Postgres, and only Postgres
The original offered five containers. Of those:

  Postgres    the only database Officer has — account, passkeys, settings,
              dashboards, email accounts, the queue. Required.
  Redis       not referenced anywhere in the platform. No import of the client,
              no environment variable, no mention; the only "redis" string in
              src/ is the word "rediscover" in a comment. Dropped. (It is still
              in package.json and comes out in the dependency pass.)
  SearXNG     zero references anywhere. Dropped. If it ever arrives it brings
              its own compose file and its own Redis with it.
  Mailhog     a development convenience, offered separately rather than here.
  Nginx PM    a deployment choice — Caddy, Traefik, nginx or the tailnet — and
              not something a setup script should pick.

Provisioned into $OFFICER_ROOT/dockers/postgres/, the same convention the app
store uses: one directory per service, the compose file in it, relative bind
mounts so the data sits beside the compose file.

Bound to 127.0.0.1, deliberately and with the reason in the compose file itself.
Docker publishes ports by writing iptables rules underneath ufw, so "5432:5432"
is reachable from the internet whatever the firewall reports — the same mechanism
the machine-setup firewall section exists to close. The platform runs on this
machine, so loopback is all it needs.

The password lives in a 0600 .env beside the compose file rather than inside it,
so the compose file can be read or copied without carrying a credential. A second
run reuses it rather than minting a new one, which would leave the container and
the URL disagreeing.

Readiness is waited for rather than assumed: Postgres initialises its data
directory on first start, and db:push against a database that is still starting
fails in a way that reads as a schema problem.

Choosing an existing database checks the URL but does not insist on it — the URL
may be right and the database not yet started, and refusing to continue over that
would be worse than saying so.

Also carries the whitespace fix for the comment removed in the previous commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 22:12:10 +00:00
pastilhasandClaude Opus 5 911b79b6a2 drop a comment describing a machine that no longer exists
paths.ts carried a parenthetical explaining that the development machine had the
layout inverted — the project inside ~/dockers/officer.dev/, so the root derived
to officer.dev and the app store's directory came out as a dockers inside a
dockers.

That machine is gone. The project sits at ~/officerdev/platform, which is the
clean shape the comment said new installs would get. Anyone reading it now goes
looking for a directory that is not there and comes away unsure whether the
derivation can be trusted.

The rule above it is unchanged and is the whole contract: data/ is a direct child
of the root, and OFFICER_ROOT is dirname(DATA_PATH).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 22:07:19 +00:00
pastilhasandClaude Opus 5 cec8fbe57e the acl check could not fail, because sudo drops DATA_PATH
Review of 76cd7c2. The ACL finding is right and the fix is correct — verified here that `setfacl -R -P -b`
removes the default entries as well as the access ones, which the man page splits between -b and -k and
does not settle. `acl` is already a core package in setup.sh, so the new hard dependency is real.

But the checker it added cannot fail in the way it is documented to be run.

`assert-uid-free.sh` is invoked as `sudo ./assert-uid-free.sh --check ...`, and sudo's env_reset DROPS
DATA_PATH, so the script falls back to the hardcoded `/home/pastilhas/officerdev/data` — which is not this
machine's data directory and does not exist. Every check in the file is "look for X, report ok when nothing
is found", so a missing root reports clean without looking. Demonstrated: a tree carrying both
`user:65534:rwx` and `default:user:65534:rwx` was reported as `ok  no ACL entries naming uid 65534`.

The ACL check is the one that fails silently and completely, because it is the only one scoped to DATA_PATH
alone — the uid and subuid scans still walk /home and would catch something. So the check just added to
catch the hazard ownership cannot see is the check a wrong DATA_PATH disables.

Fixed by refusing rather than passing:

  require_roots       every search root must exist, or exit 2 naming it and showing the sudo invocation
                      that preserves DATA_PATH
  numeric guard       uid/start/count must be numbers. deprovisionOsAccount logs '<no-subuid-range>' in
                      that position for an account with no /etc/subuid entry, and pasting that log line in
                      — which is exactly how it is meant to be used — made sub_end empty and turned the
                      range scan into a no-op.

The handler's audit line now prints DATA_PATH inside the command it tells the operator to copy, and says
so explicitly when there is no subuid range rather than emitting a command that cannot work.

Verified: bogus root exits 2, non-numeric range exits 2, and the ACL check FAILS on a specimen tree
carrying the entries — the "make it fail before trusting it to pass" step from the spec's own subuid
section, now done for the ACL half too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 04:34:30 +00:00
pastilhasandClaude Opus 5 76cd7c20bf sever the ACL as well as the ownership
severMemberTree reassigned the tree and left the access-control entries behind. confineUserTree
grants each member a named ACL on their whole tree — u:<uid>:rwx plus a default: copy — and
chown does not remove them: they are xattrs rather than ownership, and they record the uid
numerically.

Measured before this change: after chown -h -R to the service user, user:<uid>:rwx was still
present on the directory, on its children and in their defaults. The tree read as the
platform's while still granting the freed uid read and write on every byte, so the next account
allocated that number would inherit the previous member's home, keys, credential and container
storage — the hazard this file exists to prevent, reached through a door that find -uid cannot
see.

Now chown then setfacl -R -P -b. Proven on a scratch tree: owner 1001 with five entries naming
1001 becomes owner 1000 with none.

-b rather than removing the member's entries alone, because the service user owns all of it
afterwards and "no ACLs" is cheaper to verify than "no ACL naming one id". -P is already the
default for a recursive setfacl — verified, a symlink out of the tree was not followed — and is
stated for the same reason the chown above carries -h: a member chooses what their symlinks
point at, and this argv should not rest on a traversal default holding.

Found by running assert-uid-free.sh against a real tree; the spec and the checker had the same
blind spot and were corrected in a2f63dc5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 04:28:59 +00:00
pastilhasandClaude Opus 5 46799dada8 deprovision a member's linux account when the platform account goes
Implements docs/deprovision-os-account.md. Until now deleteUserHandler removed the row, cascaded the
database, and left the entire Linux side running — measured on production on 2026-08-12: working login
shell, healthy postgres container, 454M of data, uid queued for the next useradd to reissue along with
everything still owned by it.

The load-bearing rule from the spec: sever the data from the uid BEFORE releasing the uid, and if
severing fails, do not release. A failed deprovision is not a broken account, it is a trap for whoever
is created next.

Sequence: disable-linger, terminate-user, reap-and-prove, chown -R, userdel (never -r).

  reap    terminate-user is not a barrier. Production measured a three-hour-old `/bin/zsh -i` surviving
          it AND the removal of /run/user/<uid>. So: pkill, bounded wait, pkill -9, bounded wait, and a
          final count that must be zero or the account is not released.
  chown   fixes the uid and subuid halves in one pass — it rewrites every file it walks whatever owned
          it. The range is still captured first, because userdel removes the /etc/subuid entry and after
          that nothing on the machine remembers what it was. It is returned on every path including the
          failures, and logged as the exact assert-uid-free.sh command line.

Two guards the spec did not ask for, both pure and unit-tested:

  guardDeletable    ensureOsUser's adoption rule backwards. Deletable only if the passwd home is the one
                    the platform would have confined, and uid >= 1000. Without it `userdel root` is one
                    bad users.osUser away and nothing else in the sequence would object.
  guardMemberTree   the tree must resolve to a direct child of DATA_PATH. The email reaches join() from a
                    database row and the result is the argument to a recursive chown.

chown runs with -h. Measured here that `chown -R` already declines to follow a symlink out of the tree and
re-owns the link itself, but the argv should say so rather than rest on traversal semantics — and
re-owning links is what makes `find -uid` (lstat) a meaningful check afterwards.

destroy exists, has no call site, and is chown-then-delete-as-the-service-user rather than sudo rm -rf, so
a recursive root delete built from a database column does not exist in this codebase.

deleteUserHandler now runs this FIRST and refuses to delete the row if it fails: the row is what remembers
there is anything to clean up, so deleting it first makes a failure unrecoverable through the UI.

NOT YET RUN AGAINST A REAL ACCOUNT. Only the pure guards have tests. The five-step validation is in the
doc; it needs the production host, a shell left open, and a container writing as a non-root user — the two
cases the quiet path passes vacuously.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 04:19:50 +00:00
pastilhasandClaude Opus 5 f5f509a99d scripts/setup is the initial install, nothing else
Two of the eight did not belong. cleanup-desktop.sh is the teardown — the inverse of an install, not part
of one. provision-user-dirs.ts runs per account at invite time, on a machine that is already set up.
Both are back at the top level, with their `../` derivations and usage strings put back.

What is left is what a fresh machine runs once: the two installers (setup.sh, setup_mac_light.sh), the
two things setup.sh calls (setup-dockers.sh, setup-desktop.sh), and the two files they deploy —
starship.toml, copied to ~/.config, and officer-set-display.sh, which setup-desktop.sh installs to
~/.local/bin as a login-time mode setter. The last one is not a setup script and does not read like one;
it is here because it is install payload, same as the toml, and setup-desktop.sh loads it by
`$(dirname $0)`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 04:05:06 +00:00
pastilhasandClaude Opus 5 9c353f5f0d move host setup into scripts/setup/
scripts/ was holding two unrelated kinds of thing: install-this-machine, and run-this-occasionally. The
eight installers now live in scripts/setup/; what stays at the top level is the build steps (gen-index,
prebuild, build/) and the two maintenance scripts (reindex-music, rebuild-soulseek-tree).

The move is not just a rename. Three of these derive the repo root from their own location:

  setup.sh:51            PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
  setup_mac_light.sh:51  same
  cleanup-desktop.sh:134 ENV_FILE="$(dirname "$0")/../.env"

Left alone, all three would now resolve to scripts/ — and nothing downstream complains. PROJECT_DIR is
where .env is written, where `bun install`, `gen:index` and `db:push` run, and what pm2 is pointed at, so
a fresh install would have quietly provisioned scripts/ and reported success. cleanup-desktop.sh fails
the other way: it would find no .env, print "No .env — skipping", and leave the real VNC_PASSWORD in the
real file. All three are now `../..` with a comment saying why the level matters.

provision-user-dirs.ts imports data-path.ts relatively; that one tsgo caught.

Also disambiguated `setup.sh` where it had become two files. app-store/templates/<name>/setup.sh is a
per-sidecar installer with its own contract, and preflight.ts + docs/sidecar-app-store.md discussed both
in the same paragraph. The host one is now spelled with its full path at those sites.

Verified: bash -n on all six shell scripts, tsgo clean, os-user tests pass, both derivations resolve to
the repo root, starship.toml still resolves from os-user-shell.ts, and provision-user-dirs.ts runs under
DRY_RUN.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 04:02:19 +00:00
pastilhasandClaude Opus 5 ec1997fd0e a chat with no chosen directory runs in the caller's own home
The default was DATA_PATH/<email>/general_chat_sessions, a dedicated directory so /chat
sessions formed their own Claude project group instead of cluttering the home. It is a sibling
of the home, and confineUserTree makes every sibling the platform's at 0700 because the others
are attachments and email_accounts. So it was unreachable for a member: the first live member
turn started there and every Bash call failed on its own working directory before doing
anything.

A per-member copy inside each home fixed the symptom and left two rules to remember. The owner
chose one rule instead — the account's own home, whoever they are — and accepted the trade
knowingly: /chat sessions now share a project group with anything else run from that home,
which was the reason the dedicated directory existed.

Removed rather than left dangling: getGeneralChatSessionsCwd, ensureGeneralChatSessionsCwd,
ensureMemberChatCwd, and general_chat_sessions from USER_DIRS so new accounts stop getting it.
Existing directories are untouched and their transcripts stay where they are — Claude groups by
cwd, so the owner's old /chat history remains under its own project slug rather than moving.

The UI labels move with it: the default group now reads "home" rather than naming a directory
that no longer has a role.

ChatIdentity keeps carrying both email and home. The pairing was justified in the comment by
general_chat_sessions being email-derived, which is now gone — but the distinction it encodes
is real (the email says who, the home says where), so the comment explains that instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 01:54:19 +00:00
pastilhasandClaude Opus 5 6c84c74c91 give a member a chat cwd they can actually enter
host captured the first live member turn: uid 1001, nine env vars, zero
ANTHROPIC_*, zero POSTGRES_URL, zero JWT_SECRET. The privilege drop and the
allowlist both held. One defect.

The default chat cwd was DATA_PATH/<email>/general_chat_sessions — a sibling of
the member's home, which confineUserTree deliberately makes the platform's at
0700 because the other siblings are attachments and email_accounts. So the turn
ran in a directory the member cannot enter, and every Bash call failed on its
own cwd. The agent reported its shell as broken, which was true.

A member's default is now ~member/general_chat_sessions, created as them through
runAs. mkdir -p, so it is idempotent per turn and needs no reprovision. The
owner's path does not change, and the sibling stays 0700 — loosening it would
trade a broken shell for an open directory holding attachments and mail.

29 said this path is email-derived and therefore stays email-derived. True, and
it did not follow that it is usable: an email-derived path under DATA_PATH is
precisely the set a member is locked out of. Splitting identity from filesystem
path was right; assuming the identity side was inert was not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 01:44:43 +00:00
pastilhasandClaude Opus 5 2a8f0049a3 handle OSC 52, so "press c to copy" reaches the user's clipboard
xterm.js does not handle OSC 52 unless something registers for it, and nothing did. A program
offering "press c to copy" emitted the sequence and it vanished, so its confirmation was true
about having sent it and false about anything arriving.

Found while signing a member into Claude Code on the production host: its first-run login
prints an OAuth URL too long to read off a wrapped pane and offers to copy it, "(Copied!)"
appeared, and the clipboard was untouched. The URL had to be recovered by running claude under
tmux on the server and reassembling it from the captured pane — which is not a thing a member
can be asked to do, and first-run login is every new member's first five minutes.

Writes only. A lone `?` in the data position is a read request — a program asking the terminal
to hand over whatever the user has copied — and it is deliberately not answered: a shell should
not be able to exfiltrate the clipboard of the person watching it.

The clipboard API needs a secure context and generally a user gesture; the keypress that caused
the sequence is that gesture. A refusal is swallowed rather than thrown, since a copy that does
not land is the status quo rather than a reason to break the pane.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 01:34:45 +00:00
pastilhasandClaude Opus 5 532ad15ac1 drop the import the gate left behind
c59df4f removed the isSuperAdmin middleware and left its import, so the only two
matches in the file were the dead import and the comment explaining what used to
be there. Harmless and misleading: a grep for isSuperAdmin should not find chat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 01:12:35 +00:00
pastilhasandClaude Opus 5 c59df4f866 let members use chat
The owner authorized this explicitly. Two refusals removed together, because
they were always one guard in two places: the wholesale isSuperAdmin middleware
in api/chat/chat.ts, and the chat socket's 403 in server.tsx.

They were right for the day they stood. A turn spawned claude as the OWNER and
every transcript path resolved through the owner's home, so a granted member
would have read the owner's sessions and run an agent as them.

What replaced them, rather than what deleted them:

  the turn runs as the member    spawnClaudeAsMember through sudo setpriv,
                                 proven against a real account by reading file
                                 ownership rather than trusting the process
  the credential is theirs       --reset-env plus an allowlist, so the owner's
                                 proxy variables cannot cross
  the transcripts are theirs     ChatIdentity carries a home from resolveHomeDir
                                 and claude-sessions cannot invent one
  the sessions are theirs        every session records its owner and all six
                                 sidecar commands refuse a mismatch

Also adds the precondition host asked for in 10: a member whose claude is not
signed in gets the instruction rather than a turn that dies on an auth error and
reads as a broken agent. Not installed and not signed in are separate messages
because they need different actions.

registry.ts and registry.test.ts now describe chat as confined in fact rather
than ahead of its implementation. The comments at both former guards say what
had to exist first, and that a revert should go back to a refusal rather than to
a narrower one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 01:12:00 +00:00
pastilhasandClaude Opus 5 c5522700ff compare the binary as a string, and prove the uid by file ownership
host ran the live test against green. Two results.

SETPRIV WORKS. The privilege drop lands on the member and the SDK spawn survives
it, so the design does not change shape and everything layered on the hook
stays. That was the last question that could have moved the architecture.

THE BINARY CHECK REFUSED A BYTE-IDENTICAL PATH. sameFile used realpathSync,
which has to stat inside a 700 home the platform is `other` to, so it threw
EACCES and the catch turned that into "not their binary" — every member turn
refused forever, the moment the gates moved. Failing closed was the right
direction and it made the feature impossible rather than unsafe.

Now resolve(command) !== claudeBinIn(run.home). Both operands are computed by
the platform from the same function, so string equality establishes exactly what
the check is for and needs no access to their home. sameFile and its tests are
deleted: a helper kept for a case that cannot arise is a trap for the next
reader. The realpath version was defending against an upstream that normalises
paths, and there is no such upstream — the platform controls both ends.

The fact that decides this is that the check runs in the PLATFORM process, not
the member's, and neither of us stated it until EACCES did.

THE TEST WATCHED THE WRONG PROCESS. child.pid is sudo, whose real uid is
legitimately the service user's until it execs down through setpriv, so
asserting on it fails on a working drop. Split: --version through the hook
proves their binary ran, and a second spawn creates a file in their home whose
owner the test stats. Nothing self-reports, and a process cannot forge the uid
that owns a file it created.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 01:03:17 +00:00
pastilhasandClaude Opus 5 9bab67aca5 test the privilege drop without lifting a gate
host caught a circularity I had written twice: do not lift the chat gates until
a member turn has been watched running, but a member turn goes through chat and
chat refuses non-owners. With the gates up there is nothing to watch; with them
down the thing we wanted proven has already shipped.

spawn-as-member.live.test.ts calls spawnClaudeAsMember directly against a real
provisioned account — no gate, no chat, no SDK. The child's uid is read from
/proc/<pid>/status, so it is the kernel's answer rather than anything the child
chose to say, and it asserts >=1000 and not this process's uid: a failed
privilege drop cannot pass by running as the service user. It also asserts the
binary exited 0 having printed a version, which proves their install ran rather
than merely being spawned, plus a negative that /bin/sh through the same hook
throws.

Opt-in via OFFICER_TEST_MEMBER and OFFICER_TEST_MEMBER_HOME, because it needs a
provisioned member with claude installed — which exists on the production host
and on no developer machine. A run without them skips loudly rather than
reporting an empty file as a pass.

Also adopted host's NO REPLY NEEDED terminator: "reply to everything" had no
exit condition and cost the owner two agents being polite at each other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 00:57:18 +00:00
pastilhasandClaude Opus 5 1575df3f78 stop building cwds out of an email address
host found a live regression from 95951fb, on the owner's own paths.

resolveBaseCwd used to take an email and resolve its own root. 95951fb made the
first parameter the home itself, and three callers outside that diff kept
passing an email: pipeline-executor twice and agent-handoff once. Both
parameters are string, so tsgo had nothing to say. Every pipeline step and
handoff with a relative cwd, a `~`, or no cwd was building a path out of an
address, resolving it against the platform's own working directory — the
checkout. Absolute paths kept working, which is what would have made it look
intermittent.

All three are owner-only, so they now pass getOwnerHomeDir(email) explicitly,
the way agent-runner does. The definition of resolveBaseCwd carries the warning:
an absolute path, NOT an email, with the reason.

Not done: the branded type this argues for. Two strings meaning "identity" and
"filesystem path" sat adjacent through a refactor and the compiler could not
help, which is a real gap — but it reaches every path function in the server,
and doing it at 01:00 on the back of a bug caused by a hasty refactor would be
the joke telling itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 00:51:09 +00:00
pastilhasandClaude Opus 5 95951fbe5a resolve transcripts and cwd against the caller's home, not the owner's
The history layer, and the last change that could be made without a live member.

claude-sessions.ts had `claudeHome = process.env.HOME_DIR ?? join(DATA_PATH,
email, 'home')`, which discards its argument whenever HOME_DIR is set — always,
on a real install. Every transcript read therefore resolved to the OWNER'S
~/.claude no matter who asked, and the comment above it asserted "single-user
platform" as though that were a property rather than an assumption. A member
reaching these functions would have been handed the owner's conversation list.

Now every read takes a ChatIdentity {email, home} with the home resolved from
resolveHomeDir(userId), and this file has no way to invent one. Both fields
travel together because they are genuinely different: general_chat_sessions
lives under DATA_PATH/<email>, not under a home. Collapsing them would be the
same class of mistake as undefined meaning "the owner".

websocket.ts's resolveCwd takes a home, so `~` expands against the caller's own.
Identity is resolved BEFORE the cwd — expanding `~` before knowing whose home it
is would be exactly the bug being removed — which also let a duplicate
resolveTurnIdentity call from 6aeb304 be deleted.

chat.ts resolves per request and throws FORBIDDEN rather than falling back, same
posture as resolveTurnIdentity. agent-runner passes the owner's home explicitly
rather than inheriting it, since that path really is owner-only.

Made at 01:00 after saying it should not be. Three things I am least sure of are
listed in COMMS 29 rather than left for the reviewer to find: chat routes now
have a failure mode they did not have, resolveBaseCwd's exported parameter
changed meaning rather than shape, and the bare-email rewrite in chat.ts was
mechanical with hand repair.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 00:48:09 +00:00
pastilhasandClaude Opus 5 6aeb304f56 never spell "I don't know whose turn this is" as "the owner"
host caught that resolveMemberRun failed open. Returning undefined means "run as
the server owner" downstream — their binary, their ~/.claude credential, their
HOME, their MCP config carrying OFFICER_AUTH_TOKEN — and three different inputs
produced it: the caller being the owner, resolveHomeDir failing, and a member
whose osUser is null. The last two mean "could not determine", and answering
them with the owner's identity is the single thing this feature exists to
prevent.

23's own comment said the caller must not fall back to the owner. The code did
exactly that. The prose was right.

Now a discriminated TurnIdentity: owner, member, or refuse-with-a-reason. The
call site ends the turn on refuse instead of spawning. The owner's identity is
reachable only by positively establishing isOwner, never by failing to establish
anything else — resolveHomeDir already reported it as a positive fact and the
funnel through undefined was the only thing discarding it.

The null-osUser case is not hypothetical: provisionOsAccount is non-fatal at
every stage and records the account either way, as its own source says. Tonight
provisioning failed three separate ways on a real member and the account
survived each time.

No test yet, and the reason is in COMMS rather than hidden: it needs database
fakes this repo has no pattern for, and inventing one at 01:00 to cover four
branches is how the next defect gets written. The union is exhaustive, so tsgo
catches a missing case — not the same thing, not nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 00:38:39 +00:00
pastilhasandClaude Opus 5 311b2ea55c populate member from the authenticated socket
The last mechanical link: chat socket -> resolveMemberRun(userId) ->
ClaudeSpawnStreamingParams.member -> claude-manager's branch ->
spawnClaudeAsMember -> sudo setpriv. The path from a request to a privilege drop
is now complete.

Resolved from the authenticated socket, never from the client message — the same
rule server.tsx applies to the pty sidecar, where it deletes any client-supplied
osUser/home from the query string before setting its own.

resolveMemberRun returns undefined rather than throwing when a home cannot be
resolved, because undefined means "the owner" downstream: an account with no
Linux user has nothing to confine a turn to, and falling back to the owner is
the one wrong answer that must not happen by accident. A separate function with
that reasoning attached rather than an inline ternary.

Still inert. Both gates refuse non-owners before this line is reached, so the
only path that reaches it today returns undefined via isOwner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 00:33:03 +00:00
pastilhasandClaude Opus 5 8c4f150c15 stop one conversation's transport hiccup from killing every session
host found this while we were elsewhere: four agent-sidecar crashes tonight, one
truncating the owner's turn mid-sentence.

  error: ProcessTransport is not ready for writing
      at write (…/claude-agent-sdk/sdk.mjs)   <- no frames from our code

A floating rejection inside the SDK's own input pump, so no await of ours could
have caught it. With no handler anywhere in src/servers it reached the top
level, Bun exited, PM2 restarted, and every live session on the machine died —
not just the one whose transport failed.

That is 975673a for the second time. It fixed the one path someone had thought
of, a Postgres query throwing, and its own message named the consequence: "any
Postgres restart killed every live agent session on the machine". The general
case had no backstop.

Now logged loudly and survived. This does not pretend an unhandled rejection is
fine — it makes it debuggable instead of fatal.

uncaughtException is deliberately not handled the same way: a rejection leaves
the process's state intact, a synchronous throw that unwound to the top may not
have, and continuing on a corrupted heap is worse than restarting. Same blast
radius either way, which argues for durable sessions rather than for surviving
anything at any cost.

The trigger is untouched and unconfirmed — most likely the CLI exiting while
streamInput is still pumping. That needs catching in the act on the live host.

Also worth knowing, since d59adbf interacts: this restart sweep runs several
times an hour on that machine, and a session with no recorded userId is now
skipped rather than asked about, so it stays marked generating until a
reconnect. A stuck "generating" spinner after an agent restart is that, not the
UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 00:23:20 +00:00
pastilhasandClaude Opus 5 d59adbf1f2 scope the six sessionKey commands to their caller
The control surface half of 7cb402b, and the code-side blocker on the gates.
kill, interrupt, clear-session, is-generating, find-session and list all took a
bare sessionKey, so any caller who could reach them could act on whichever
session happened to match — and list returned every session in the sidecar,
which host rightly called a disclosure on its own, before anyone kills anything.

All six now carry userId, resolved from the authenticated request and never
taken from the client, and every handler enforces it through one ownedSession
helper. list is filtered rather than labelled. find-session is scoped because it
is the reattach hinge: a browser holding a transcript uuid it should not have
would otherwise be handed the session key that drives it.

"Not yours" and "does not exist" answer identically everywhere, which is the
same choice getClaudeSession made: every caller treats them the same, and a
distinct answer for the second confirms to a guesser that a session exists under
a key they do not own.

One behaviour change beyond the scoping. endTurnIfAgentIsGone sweeps sessions on
a sidecar restart, and a session with no recorded userId now has no safe id to
ask as — asking as the owner would answer a member's orphaned session with the
owner's authority. It is skipped, so it stays marked generating until the next
reconnect corrects it, which is what happened before that loop existed.

This removes the code-side reason the gates cannot move. It does not make them
movable: no member has signed in, no member turn has run, spawnClaudeCodeProcess
has still never been called, and lifting them was never mine to decide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 00:17:53 +00:00
pastilhasandClaude Opus 5 9833822625 pass the member's gid instead of reusing their uid
host caught that provisionRootlessDocker had no gid field, so the new
install -d passed uid in the group position. Correct on this host only because
useradd allocates a per-user group; wrong on any account whose gid is not its
uid — one created by hand, one on a host whose login.defs uses a shared group,
or one ensureOsUser adopted rather than created.

Mode 700 means the group triad grants nothing, so nothing breaks today. That is
what makes it worth fixing now rather than later: it would surface only after
somebody widened the mode for an unrelated reason, and then not obviously.

The call site already held account.gid from ensureOsUser — the same value the
.local fix used correctly earlier the same night. Threaded through rather than
derived, and the field carries a comment saying why it is separate from uid,
since they are equal here and a reader would ask.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 00:13:50 +00:00
pastilhasandClaude Opus 5 893130940e create docker's storage ourselves, so the strip is not a race
host verified green's reprovision: claude 2.1.228 installs and runs as the
member, the file browser reads their home, rootless Docker runs and sees 0
containers while the owner has 8. First end-to-end proof of any of this.

One thing came out dirty. ~/.local/share/docker carried the home's inherited
default ACLs after a "successful" strip, because the strip was guarded on
existsSync and only the daemon creates that directory. On a first run the guard
was false and the strip no-opped; the retry then started the daemon, which
created the directory and inherited the defaults. The run meant to clean it up
was the one that made it, and the guard could not tell "nothing to strip" from
"nothing there yet".

Now created by us before the daemon exists — member-owned, 700, nothing to
inherit — and the strip is unconditional afterwards, repairing an account
provisioned before this and no-opping on a clean one. A guard that depends on
another process having got there first is a race however it is written; the fix
is owning the order rather than testing for it.

Third bug of this class tonight: an implicit parent directory, a strip guarded
on another process's work, and an installer piped into the wrong shell. All
three were invisible until a real member account existed, which is the argument
for making the second one sooner than feels necessary.

Left alone deliberately: .local being unreadable by the platform (a decision
about intent, not a defect, and the owner's), and the -u 70 + bind mount
observation, whose probe host already distrusts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 00:08:55 +00:00
pastilhasandClaude Opus 5 ef000aaf51 pipe the installer into bash, and stop inventing a root-owned .local
Green's first provision failed three ways. host caught all three on the live
box; two are fixed here and the third is his to bisect.

THE INSTALLER IS BASH AND WE PIPED IT INTO SH. A script read on stdin never has
its shebang honoured — the interpreter you name is the one that runs it — and
install.sh declares #!/bin/bash and uses [[ =~ ]] on line 9. On Ubuntu /bin/sh
is dash, so it died with `Syntax error: "(" unexpected`, which reads like a
corrupt download rather than the wrong interpreter. scripts/setup.sh carried the
same line for the owner's own install and is fixed too.

INSTALL -D CREATED ~/.local AS ROOT. `install -d` makes missing parents but
applies -o/-g/-m only to the final component, so blessing ~/.local/dockers
invented a root:root .local inside the member's own home. Rootless Docker then
died on `mkdir …/.local/share: permission denied`, and the Claude installer
targets ~/.local/bin, so fixing the shell alone would have hit this next.

That is 71589ae for the second time — same function shape, same silent parent,
same class of consequence. Its own commit message said this surfaces "weeks
later as one tool mysteriously failing"; it took twenty minutes. Grepped the
other install -d/-D sites: os-user-shell already creates its parent explicitly,
os-user-ssh has no implicit parent.

NOT fixed: the file browser's ACL mask on a member home, where access mask is
--- while default:mask is rwx. That pattern means a chmod ran after the setfacl
and clamped only the access side, so the primitive is right and something later
is wrong. host has the live filesystem and has already half-excluded the
suspect; guessing from here would churn a working block. Noted that this commit
adds an install -d before the one he was about to bisect, so it wants a
reprovision first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:46:59 +00:00
pastilhasandClaude Opus 5 7cb402b25a chat sessions record whose they are, and refuse a mismatched caller
host found this reading 10: chat sessions carry no identity at all. state.ts
held a flat sessionKey -> transcript uuid map, the in-memory sessions Map was
keyed the same way, and websocket.ts takes sessionKey and resumeSessionId
straight off the client message. 4d4a253f fixed exactly this for the pty
sidecar — "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" — and chat never got the same treatment, because both gates
made it unreachable and therefore invisible.

Sessions now carry userId, persisted and in memory. getClaudeSession requires
the caller and returns undefined on a mismatch rather than throwing, since a
throw confirms that someone else's session exists. spawnClaudeStreaming throws
when a live session's owner does not match — that is the path that mattered
most, because handing over another account's sessionKey would otherwise push a
turn into their conversation and stream their agent's output back.

Legacy string entries are adopted to the owner on load. That is a statement
about the past rather than a guess: until this commit the gates refused every
non-owner, so nothing else could have created one. Dropping them would have
silently broken the owner's resume on upgrade.

PARTIAL, and the doc says so plainly: claude:kill, :interrupt, :clear-session,
:is-generating, :find-session and :list all still take a bare sessionKey with no
ownership check, and :list returns every session in the sidecar. Closing them is
a wide mechanical change across the protocol, the registry verbs and their
producers, and it belongs in its own reviewable commit rather than buried under
a state migration. The gates must not move on the strength of this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:33:08 +00:00
pastilhasandClaude Opus 5 3e0daee611 chmod the mcp config, because writeFileSync's mode never fires on it
host measured what df450318 assumed. writeFileSync passes `mode` to open(2),
which honours it only when it CREATES the file — on an existing one the call
truncates and writes and the mode is ignored. So the fix worked on a fresh
install and did nothing at all on every box already leaking, which is the whole
exposed population. Verified on production after the commit: still 0644, still
readable by a member.

That is worse than not fixing it, because it closes the ticket. The exposure
would have continued through every bootstrap with nobody watching for it.

chmodSync after the write, both kept — the creation mode closes the window
between open and chmod on a fresh write, and chmodSync is what reaches an
install that is already leaking. Commented so neither is deleted as redundant.

Still not closed on disk: the file is the owner's to chmod and the token is
theirs to rotate, and no commit reaches either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:45:34 +00:00
pastilhasandClaude Opus 5 df4503180f stop handing a member's turn the owner's mcp config, and close the file
host found a live credential exposure while answering my question about what
else the member branch missed. It was outside the diff, and predates all of it.

MCP-CONFIG WAS NOT BRANCHED. mcpHostPath is module-level, written once at the
owner's bootstrap, and was applied to every turn. Its env block carries
OFFICER_AUTH_TOKEN, a 30-day JWT signing as the owner — so a member's turn would
have spawned their MCP server holding it. Now inside the params.member ternary
alongside the binary and the spawn, for the reason already written there: these
values say whose turn this is and have to move together. A member gets none.
What they should get instead is undecided, and undefined beats the owner's.

THE FILE WAS 0644. Written with a bare writeFileSync into a 755 directory, on a
host where `terminal` is granted to every role by default — so any member could
cat it and hold owner-level API access on loopback. host verified that as a real
member on the production host rather than reasoning about it. Now 0600.

The mode is the only half of that which is code. The token has been
world-readable and stays compromised until rotated, the directory chain above it
is still 755, and neither is fixable from a commit. Both written up for the
owner in COMMS 05, along with why I am stopping here rather than continuing:
the next commit should be the rotation, not more feature work stacked on top of
an open exposure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:42:22 +00:00
pastilhasandClaude Opus 5 fbabc22ed7 wire the member branch into the SDK spawn, still unreachable
ClaudeSpawnStreamingParams takes an optional member {osUser, home}; createSession
branches on it, using their binary and spawnClaudeAsMember together, or the
owner's CLAUDE_BIN as before.

THE BINARY AND THE PRIVILEGE DROP ARE ONE BRANCH ON PURPOSE. settingSources
makes ~/.claude authoritative for settings and ~ is whatever HOME the process
gets, so pointing the SDK at a member's binary while spawning as the service
user would read the OWNER'S settings and credential while running the member's
code — and it would look like it worked.

cwd defaults to member.home before HOST_HOME for the same reason: HOST_HOME is
this process's home, so a member would start in a directory they cannot read and
the failure would present as a broken agent rather than a wrong cwd.

Nothing populates `member`. Both gates refuse non-owners before any of this is
reached, so the delta is that spawnClaudeAsMember now has two importers instead
of one, and neither path a user can take changes. Verified rather than assumed,
since host made it a condition: both gates intact, 84 tests pass.

Not authorization: host gave an opinion on wire-first and deferred to the owner,
who has not ruled. Corrected in COMMS, where 01 had overstated it. The gates
come off on the owner's word alone; this reverts as one commit if the answer is
no.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:27:45 +00:00
pastilhasandClaude Opus 5 baa2d29fa4 read the probe off stdout by position, and renumber comms
host's finding on 288679af, and the directory restructure the owner asked for.

THE MARKERS WERE SUBSTRINGS OF THE PATHS THEY TESTED. `bin` is inside
…/.local/bin/claude and `cred` is inside …/.claude/.credentials.json, and the
match ran against a string that merged stdout and stderr — so anything writing
either path to stderr set the flag. Verified on the live server against an
account with neither file: one `set -x` made the trace of the test command
itself report installed and signed in. Not live, and it fails in the unsafe
direction, on the endpoint whose whole job is explaining a broken agent.

No marker spelling fixes it, because a trace echoes the literal along with the
path. The channel was the bug. Two characters on stdout read by position, with
parsing extracted as parseLoginProbe so it cannot see stderr at all, and stderr
kept separately because a failure has to stay diagnosable. Seven tests including
the exact trace host captured — true/true before, false/false now.

COMMS is renumbered: ten dated files in a day, each restating the others'
status, replaced by one file holding only what nobody has resolved. Odd numbers
mine, even numbers host's, alternation encoding push-then-wait, numbers ending
when the feature does. The reasoning that produced the deleted files is in the
commit history, which is where it belongs.

Carried forward and unowned: deprovisionOsAccount, the terminal replay bug, the
two docker handbacks, and the two verify items neither of us can execute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:23:27 +00:00
pastilhasandClaude Opus 5 288679af09 one sudo call for both agent-status answers, not two
host's finding on 6b7aad91. claudeLoginState ran two `runAs` probes in parallel,
and each is a `sudo -n setpriv` fork/exec that writes a line to
/var/log/auth.log. It is reached from /agent-status, which sits on a grant every
role holds by default, so a polling UI would have cost two sudo spawns and two
auth-log lines per poll per member — cheap individually, unbounded in aggregate,
and the auth log is where a real sudo event has to stay visible.

One call answering both questions with markers instead of two exit codes. Did
not take his second suggestion of caching `installed`: one call per request is
cheap enough that a second mechanism with its own invalidation is the worse
trade, and that judgement is recorded in COMMS so a polling UI can revisit it.

Also carries the verify list he asked for, including the pertento host key I
accepted on first use so git could reach his remote — he can compare it against
the server, which I cannot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:14:21 +00:00
pastilhasandClaude Opus 5 6b7aad91db make both env guards able to fire, and follow the symlink
host was right twice, including about his own advice. Two dead guards had
shipped here, both for the same reason: written inside the spawn closure, where
the only way to reach them is to spawn — and the passing path spawns sudo. So
nothing ever demonstrated either one firing.

THE SUBSET CHECK WAS ALSO TAUTOLOGICAL. `permitted` came from the same constants
memberEnv builds childEnv from, so it was empty under every edit where that
holds — the exact criticism that retired NEVER_ENV. Worse, it lost the one live
trigger the denylist had: a credential added to ALLOWED_ENV used to throw, and
under the subset check widened the permitted set in the same motion and passed
silently. That is the realistic future edit and it was the one left unguarded.

Now both, and the denylist tests the LIST rather than the instance, so it fires
on exactly that edit. Extracted as `assertEnvSafe` so a test can pass a poisoned
allowlist — the guards being untestable in place is why they were decorative
twice.

THE BINARY CHECK WOULD HAVE THROWN ON EVERY TURN. Anthropic's installer puts a
symlink at ~/.local/bin/claude into a versioned directory; resolve() does not
follow symlinks, so the string compare matched only while `command` arrived as
the symlink spelling, and would have failed the moment anything upstream
normalised it — at exactly the point the hook gets wired. Compared through
realpathSync on both sides now, per turn and never cached, since `claude update`
moves the target.

Nine tests pin all of it: a poisoned allowlist, a stray key, each NEVER_ENV name,
and the symlink/target/missing-path cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:10:34 +00:00
pastilhasandClaude Opus 5 2bd96a9a98 tell a member why their agent is not working, instead of 403
f0af723 granted chat to every role by default, which is right, but the route
still refuses non-owners — so a new member gets a tile that resolves and an API
that 403s, the exact broken state b4f88ec and eda004a were built to remove.

The fix is not to withdraw the grant. It is to answer the question the member
actually has, which is "what do I do about it": their own claude, in their own
home, needs them to sign in once with their own Anthropic account. The platform
cannot do that for them — logging in is an interactive act against an account
that is theirs, and the alternative, pointing them at the owner's credential
proxy, spends the owner's subscription on their turns.

GET /api-status returns two booleans about the caller's own home plus the one
instruction that fits their case, so the UI can render a terminal saying "run
claude once" rather than an error.

Its own router, deliberately not on chatRouter: that router refuses every
non-owner wholesale and is right to — reads there leak the owner's project
directory names — which means an endpoint on it could not be read by the
accounts that need it most. Same `chat` capability, no owner gate, and nothing
in the response describes anyone but the caller.

Frontend not done: nothing calls this yet, so behaviour is still unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:58:18 +00:00
pastilhasandClaude Opus 5 6a85ca5d1f the binary check that was a comment, and a guard that could fire
Four fixes from the live server's review. One was a real defect.

THE BINARY WAS NEVER CHECKED. spawn-as-member passed `command` from the SDK
through untouched while a comment claimed the member's own install was what ran.
Since claude-manager resolves the OWNER'S CLAUDE_BIN at module load, wiring the
hook would have exec'd the owner's binary as the member — the precise confusion
this file exists to prevent, asserted in prose and enforced nowhere. Now throws
unless the command resolves to claudeBinIn(run.home).

NEVER_ENV COULD NOT FIRE. It tested an environment that memberEnv builds from
ALLOWED_ENV, so a denied name was already impossible; it was also missing six
credential variables the installed SDK reads. Replaced with the subset check the
reviewer proposed: anything not in ALLOWED_ENV or {HOME, CLAUDE_CONFIG_DIR} is a
leak whatever it is called. Complete by construction, and it cannot rot as the
SDK grows variables — which the denylist provably had already.

Also: one derivation of the binary path instead of two (install resolved from
the email, exec from the home — fine until they disagree), and the constraint
that ALLOWED_ENV may never hold a secret written at the list itself, since
`env K=V` in the argv is visible in /proc/<pid>/cmdline to every account.

Not acted on, and said so in COMMS: their finding that the 711 in 401dcb7 is
inert, and that a retrofit needs a mode pass. Both are theirs. Nor pulled chat
from DEFAULT_ROLE_CAPABILITIES despite agreeing a member currently sees a tile
that 403s — that is the owner's call, not a defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:51:18 +00:00
pastilhasandClaude Opus 5 62e98dff2e a member's claude is their own binary and their own login
First half of per-user Claude. Provisioning and the privilege drop, not yet
wired to a turn — the chat gates stay up and behaviour is unchanged for
everyone. Committed unfinished on purpose so the reasoning is on the record
before the server agent runs any of it; the state is written up in
COMMS/sidecar-app-store/2026-08-11-per-user-claude-handoff.md.

THE CLAIM THAT CHANGED. docs/per-user-linux-accounts.md:226-229 says the Agent
SDK "has nowhere to put a uid", so a member's turn has to become its own
process — a change of shape rather than a flag. It is a flag:
sdk.d.ts:951 exposes spawnClaudeCodeProcess, documented for exactly this ("run
Claude Code in VMs, containers, or remote environments"), and node's spawn
already satisfies the SpawnedProcess shape it wants. So no second sidecar, no
PM2 entry, no inverted transport, and none of the registry rework a second
instance would have forced (registration is name-keyed and evicts its
namesake; the nine claude verbs resolve by capability with no selector).

THE PLATFORM NEVER RUNS AS A MEMBER. The tempting reading of "each member runs
their own Claude" is a second officer-agent under their uid, and it is wrong:
that 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 —
strictly more than their shell can do, and already forbidden by the .env boot
check. The harness stays the service user's; the thing that runs the member's
code and holds the member's credential is theirs. That is the pty sidecar's
shape, not a new one.

PER-MEMBER BINARY, deliberately, over one shared /usr/local/bin/claude. The
private part is the credential, not the executable — but claude updates itself,
and a root-owned binary is one a member cannot update, which turns "my agent is
a version behind" into a request to the owner. Same installer the owner's own
install uses, run as them, in their home. Idempotent by skipping when present
rather than re-running: the retry button reprovisions on every press.

ALLOWLIST, NOT A FILTER, for the child's environment. At the moment of the call
the calling process holds POSTGRES_URL, the JWT secret and the owner's
ANTHROPIC_API_KEY; setpriv --reset-env means nothing crosses unless written
into the argv, so an allowlist is the complete answer to what a turn can see,
and a denylist would have to be right about every variable added later.
NEVER_ENV throws rather than leaks if someone widens it.

Login is the member's own act against their own account. The platform cannot do
it for them and must not try — the alternative is lending them the owner's
credential. claudeLoginState only reports whether the credential has appeared,
and reads it as the member, so a true answer means their process can reach it.

NOT VERIFIED: any of it at runtime. tsgo passes; nothing has been provisioned
and the spawn hook has never been called. If it turns out setpriv breaks how
the SDK reaches the process, this approach is wrong and the fallback is the
earlier plan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:32:15 +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 f0af7237db terminal, chat and files are granted by default; permissions screen simplified
DEFAULTS. Every role now starts with the three confined capabilities at write, seeded in
bootstrap. These are what the platform is FOR — an account that signs in and reaches none of
them is not restricted, it is useless, and making the owner grant them by hand first is a
step with no decision in it.

Seeded as real rows rather than implied by absence, which keeps the table's one rule intact:
a missing row means no access, always, with no exception to remember. Revoking one therefore
works like revoking anything else — the row goes and nothing puts it back. Done in bootstrap
because that happens exactly once per install, so seeding can never fight a later revocation.
Non-fatal: an owner whose roles hold nothing is a one-click fix, while failing bootstrap over
it leaves a platform with no account at all.

`app` capabilities are deliberately not defaulted — they reach data the owner may not intend
to share, and each needs a sidecar before it means anything.

SCREEN. Role selection is tabs rather than a dropdown: three roles are the axis you move
along, and a select hid two of them behind a click while giving no sense of which one you are
editing. Row descriptions are gone — with three rows called Terminal, Chat and Files they
explained nothing — and the "needs a Linux account" warning went with them, since every
account now gets one at creation, so it was noise about a state that no longer occurs on its
own. `needsOsAccount` is removed from the API too, not just hidden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:25:31 +00:00
pastilhasandClaude Opus 5 3bea46f2d7 rootless docker per member — provisioning works, running a container does not yet
Not finished. Committed because the diagnosis is worth more than the code.

WHY ROOTLESS AND NOT THE DOCKER GROUP. `usermod -aG docker <user>` is the one-line version
and it is root: `docker run -v /:/host -it alpine chroot /host` is a root shell, which reads
.env, every other member's home and the wallet seed. Every boundary from today, bypassed by
one documented command. Rootless gives what was actually asked for — a daemon per account,
containers in that account's user namespace, images in their own home.

VERIFIED on this host: provisioning succeeds, the server reports 29.5.0, the daemon runs as
the member, `docker pull` puts 403 MB under their own home, and `docker ps -a` shows nothing
while the owner has four containers. That last line is the isolation, measured.

NOT VERIFIED: actually running a container. It failed, and the cause is an interaction
between two things built today:

  failed to copy xattrs: failed to set xattr "system.posix_acl_default" on …/volumes/…/_data

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. Both features correct alone. The fix here strips
default ACLs from ~/.local/share/docker only, leaving the access ACLs the file browser needs.

That fix is UNPROVEN. The re-test failed for a different, environmental reason: probe users
recycle uid 1001, and a stale lingering systemd user manager from a previous probe answered
`systemctl --user`, so the unit appeared not to exist. Cleaned with `loginctl terminate-user`.
Retest on a machine that has not had a uid-1001 user, or on a fresh uid.

Also worth knowing before this ships: uid reuse after deleting a member is a real hazard, not
just a test artefact — the next member gets the previous member's uid, and anything left
lingering belongs to them.

setup.sh gains uidmap and dbus-user-session as core packages; the shell template exports
DOCKER_HOST from $XDG_RUNTIME_DIR when the socket exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:01:17 +00:00
pastilhasandClaude Opus 5 71589aee99 a member's terminal looks like the owner's
A new Linux account opens a shell with nothing: useradd copies /etc/skel, which on Ubuntu
is a bash rc, and the account's shell is zsh — so it got no prompt, no history, no
completion, no colour. "Their own account" should not mean a worse terminal than the
owner's.

src/servers/shell-skel/zshrc is the template, and scripts/starship.toml is reused rather
than copied: setup.sh already deploys it for the owner, so one file serves both audiences
and they cannot drift. Seeded by provisionOsAccount, which means the retry button applies
it to accounts that already exist — no delete-and-recreate.

The template depends on nothing but zsh. Starship, eza, nvim, bun, deno and cargo are each
used only if present, and every path is $HOME-relative — the owner's own .zshrc has three
absolute /home/pastilhas paths in it, which is exactly what a template must not inherit.
Without starship it falls back to a zsh prompt showing the same information, because a
shell that opens with a broken prompt reads as a broken machine.

Never overwrites: written only when the file is ABSENT. ~/.zshrc.local is sourced last and
never written, so there is somewhere to put your own config that no future template can
reach.

Three fixes found by running it:

- install -D creates missing parents but applies -o/-g only to the FILE, so ~/.config came
  out root:root — readable but not writable by its owner, which would have surfaced weeks
  later as one tool mysteriously failing. The parent is now created explicitly.
- useradd took its shell from process.env.SHELL, which under PM2 is whatever PM2 was
  launched from. A member's shell depended on how the server happened to be started. Now
  chosen from what is installed: zsh, else bash.
- the pty sidecar spawned ITS $SHELL for a member, not theirs. It now execs their passwd
  shell via sh -c, so the login shell in /etc/passwd is the one they get.

starship moves out of the light-profile skip. The light profile exists to serve a file
browser, a terminal and chat — the terminal is one of its three reasons to be, and it is
what every member gets. Leaving starship out meant the fallback prompt on exactly the
installs most likely to have members. oh-my-zsh, eza and lazygit stay full-only.

Verified in a real member shell: zsh from passwd, HISTFILE in their own home, eza-backed
ll, starship active, EDITOR=nvim, and an edit to .zshrc surviving a reprovision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:43:55 +00:00
pastilhas e4acf19a35 Merge remote-tracking branch 'gitea/master' into sidecar-app-store 2026-08-11 19:10:52 +00:00