cbfe376a4208f8397c108485869fbd8595bcee4c
170
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
cc1eab7794 |
docs: a triage map of the documentation
42 documents, 13,000 lines, and no way to tell from a filename which describe the system as it is and which record an afternoon in July. This sorts them: living, stale, historical, and two clusters that want consolidating. Says plainly how much was verified — mostly filenames, status lines and greps for what changed today — so it reads as a starting point rather than a verdict. Names the two obvious consolidations without performing them. Nine opencode documents for one migration that has landed (verified: `opencode serve` is in the sidecar, so the plan's "nothing here is implemented" is false), and three mobile-dav documents that are one correspondence. Both need all of them read first, which is not a 4am job. Marks the historical ones as not-to-be-rewritten. claude-sidecar-isolation.md records the officer-claude to officer-agent rename that preceded tonight's rename to officer-claude-code; editing it to match today's code would destroy the reasoning it exists to hold. And notes what most of them share: they were written when the estate was twenty processes and everything was simply present. A core install is six. The fix is usually one line — say whether the thing is core or a plugin — not a rewrite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8826c2847e |
docs: working-on-officer catches up to the code
It described four capability kinds and said terminal, chat and files can never be granted. There are five, and those three moved to `confined` on 2026-08-11 — the kernel enforces the boundary because the account has its own Linux user, and a grant means nothing without one. The layout diagram was missing dockers/ and secrets/, and implied the paths are configured. They are derived from the working directory, which is why the pm2 cwd pin and assertInstallLayout exist. Adds what is switched off as of tonight: six core processes, every plugin router commented out beside its capability claim, the ecosystem files now generated, and .env down to three values with the keys in the secret store. First of a documentation sweep. 42 docs; this one first because it is the operational guide somebody actually reaches for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
236a3a5481 |
docs: first container test pass, and what it found
Ubuntu 24.04, Debian 12, Arch and Fedora 41. OS and package-manager detection
correct on all four; --help works unprivileged; and the install report is written
end to end in a container that had never seen this code — task 1's mechanism
confirmed off the machine it was written on.
Three real findings.
--only does not isolate a step. Running --only "Core utils" still created a user
account, because ask_username and the account creation sit in the preamble above
the step framework, so everything before the first `step` runs every time. It is
defensible and it is not what the flag appears to promise.
.setup-answers travels with a copy of the tree. Correctly gitignored and 0600,
but it lives inside the repository directory, so `cp -r` carries it — a container
that had never run setup came up already knowing the username and created that
account. Nothing secret in it; it is a surprise, which in an installer is the
expensive kind.
adduser leaks its own interactive prompt ("Try again? [y/N]") on the
account-creation path. Harmless here because the run had already stopped, but a
hang on a real unattended install.
Also records what containers cannot reach: no init means systemd, netplan, ufw
and the sshd drop-ins are only verifiable as "wrote the right file"; Docker and
Postgres are untested; macOS is unreachable entirely and everything about it is
reasoned rather than executed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
d7d64cd6d6 |
docs: the install-variant tree, for tomorrow's decision
Enumerates the forty-seven prompts the two scripts actually ask and sorts them into branches, consents and values — the distinction that decides what a generated leaf can remove. Four real branches (OS, role, tailnet state, which half), fourteen consents that let a leaf omit a section entirely, and a set of values that must stay prompts because baking them in would mean publishing somebody's hostname. Names the two things that need deciding rather than deciding them: Whether a leaf strips dead code or sets constants and calls the base. They are different artifacts and the plan rests on which one is meant — the first is what makes it auditable by being short, the second is what keeps it maintainable. And the combinatorics: 4 OS x 3 roles x 3 tailnet states is 36 leaves before consents, so the tree cannot be the full product. Publishing a few opinionated leaves keeps the static-file-anyone-can-diff property; generating on demand does not, which is the property per-leaf scripts existed for. Also notes that --unattended and a generated leaf are the same mechanism seen twice, and that install_config's existing behaviour — keep the user's file when there is no tty — is the conservatism every unattended answer needs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
cd209483e3 |
fix the clipboard over http, and audit the rest
navigator.clipboard is secure-context only, like crypto.randomUUID before it —
over plain http on a tailnet address the object does not exist. Twenty call
sites across eighteen files, in three states that all looked fine in review:
bare calls that threw and killed the handler, optional-chained calls that
silently did nothing, and one carrying the comment "Officer is always behind
HTTPS", which it is not.
The optional-chained ones are the worst of the three: a copy button that reports
success and copies nothing is indistinguishable from a working one until someone
pastes.
helpers/clipboard.ts falls back to document.execCommand('copy') over an
off-screen textarea — deprecated, and it works on any origin because it predates
the secure-context rule. Off-screen rather than hidden, because display:none and
visibility:hidden elements cannot be selected and the copy fails silently.
Reading the clipboard has no equivalent: execCommand('paste') was never permitted
from script. The file browser's paste-a-file path now checks canReadClipboard()
and explains itself instead of throwing.
docs/http-secure-context-audit.md is the full sweep the owner asked for: what was
fixed, what cannot be, and what was checked and found clear. crypto.subtle is
used nowhere in the frontend, which was the one worth confirming since it has no
cheap fallback. Notification's six matches are type names, not the API.
geolocation and navigator.share are already guarded. getUserMedia is in four
files and is being removed — but QrTransfer uses it for the CAMERA, not a
microphone, so "remove audio" does not cover it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
f1cfc0042f |
rename officer-agent to officer-claude-code
The old name said nothing about what the process runs, and it sits directly beside officer-anthropic-proxy — a different process doing a different job — so "the agent" was ambiguous exactly where it mattered. CLAUDE.md already had to spend a paragraph insisting the two are not the same thing. It spawns `claude`; the name says so now. Only two references were functional: the generator's CORE_PROCESSES and the CORE list in catalogue.test.ts. Everything else was prose or comments. Left alone deliberately: `x-officer-agent-token`. It looks like the same string and is not — it is the agent-handoff HTTP header, naming a per-panel bearer token, unrelated to any pm2 process. Renaming it would have changed a wire protocol to tidy a label. Historical docs keep the old name. claude-sidecar-isolation.md and open-threads-after-per-user-claude.md are dated investigations that record the PREVIOUS rename, from officer-claude to officer-agent, and rewriting them would make that history unreadable. CLAUDE.md notes the change instead, where somebody reading those will be looking. Also worth recording, from the owner: merging this with officer-anthropic-proxy into one sidecar was investigated tonight and rejected. They stay separate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
68f2c55ecf |
one directory per feature: schema.ts and queries.ts together
src/databases/officer_db/src/<feature>/{schema.ts,queries.ts}, replacing the
parallel schema/ and queries/ trees. 24 feature directories, 46 files moved with
git mv so history follows.
The parallel trees had drifted, which is what the restructure is really fixing:
four features were named differently on each side — app-store/sidecar-installs,
email/email-accounts, server/server-config
operations had a schema and NO query file: its task_logs is reached directly
from src/servers/api/task-logger.ts, bypassing this package's own boundary
integrations had queries and NO schema, because it spans two features'
tables — server_integrations and user_integrations
Both lopsided cases survive as directories holding one file, which states the
problem instead of hiding it across two trees.
Nothing outside the package changed how it imports. `officerdb`, `officerdb/types`
and `officerdb/db` resolve exactly as before; index.ts absorbed the path changes.
Added `"./*": "./src/*"` so the new layout is reachable — `officerdb/soulseek/schema`
— which one script needed, because soulseek is a plugin and therefore commented
out of the aggregator.
schema/index.ts became src/schema.ts, keeping the core/plugin split from earlier
tonight. drizzle.config.ts and the package's "./schema" export follow it.
Verified rather than assumed: all 52 files in the package parse, every relative
import resolves against the new layout (checked by walking each specifier to a
real file, since parsing does not check paths), and everything in the tree
importing officerdb still parses. Not typechecked — empty node_modules, frozen
installs.
One rewrite bug worth recording: the rule mapping a query module's sibling import
also matched the './schema' this pass had just written, turning it into
'../schema/queries' in 22 files. Caught by the resolver check, not by parsing —
both spellings parse fine.
Also corrects every path reference the move invalidated: src/databases/CLAUDE.md's
layout diagram, the root CLAUDE.md data section, three docs, and seven sidecar
comments naming queries/<x>.ts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
32af97e260 |
secret-store: the anthropic proxy secret moves in too
Third value for the store, agreed in conversation. Neither an encryption nor a signing key — a bearer credential the agent presents to the proxy on localhost — but it qualifies on the same properties: generated once, shared between two core processes, fatal to regenerate silently. It makes the case better than the other two, because it is not in .env. It is in $DATA_PATH/sidecar/claude-state.json, which is the exact location decision 3 rules out by name: DATA_PATH is what gets backed up. Also records the rename. ANTHROPIC_API_KEY is wrong in both halves — not Anthropic's, not an API key; Anthropic's real credential is the OAuth token in ~/.claude/.credentials.json that the proxy swaps this one for. It is anthropic-proxy-secret everywhere we control, and keeps the CLI's name only on the assignment `claude` itself reads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9eabc3ee4c |
design: the secret store
Written from the conversation of 2026-08-12. Nothing implemented; every claim about the current tree was checked on that date. The short version: secrets stay in Postgres, the keys move out of .env into a small SQLite store, and rotation becomes an operation instead of data loss. What is actually wrong today is narrower than "secrets in .env" and worth stating precisely, because the separation that exists is correct and must survive a refactor: secrets live in Postgres and the key that opens them does not. The problem is blast radius across processes — Bun auto-loads .env, so VAULT_STORE_KEY sits in the environment of all twenty pm2 processes, and officer-music holds the key that decrypts wallet seed envelopes for no reason. The document records the decisions and, more usefully, what was ruled out: Keys cannot go in Postgres. A dump would carry the ciphertext and the thing that opens it. Encrypting the key with a second key only moves the question — one secret has to be readable without any other, and the only decision is where it lives. The store is not encrypted at rest, and this was tested rather than assumed: stock SQLite silently ignores unknown pragmas, so `PRAGMA key` succeeds, encrypts nothing, and the value is readable with `strings`. bun:sqlite ships stock SQLite 3.53.0. Whole-file encryption needs SQLCipher, which is a second native dependency, and this project already knows what one of those costs. SQLite rather than a flat file for rotation, not secrecy: rotation needs key VERSIONS, since an interrupted rotation needs the old key and the new one to both exist. The file must not live in $OFFICER_ROOT/data/ — that is what people back up, and a key store in the same tarball as a database dump rebuilds the problem. It also records the core/plugin split the design assumes: light plus officer-headscale is the core, because CLAUDE.md rests the security model on the tailnet and a model that rests on the tailnet cannot treat administering it as optional. Vaultwarden and the wallet become plugins. Moving headscale into light removes it from the app store automatically, since catalogue.test.ts asserts the catalogue equals full minus light. Five open questions are left open rather than guessed at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
79da78008a |
turn the field report into something an agent can follow
Expands the communications section from a list of what worked into the actual convention: the directory's lifetime and the rule that anything durable must move to docs/ before the merge; numbering, parity as attribution, non-consecutive numbers; slugs; reply-in-a-new-file and the one case where editing your own is right; referring to commits by sha because three remotes carried the same branch names. Records what a handoff must contain, with the verified/assumed split named as the rule that carried the most weight — a handoff confident about something untested is worse than none, because the reader builds on it. Adds a skeleton to copy. Documents termination as the four attempts it actually took, ending at the only checkable version: the exchange pauses when no open item is actionable by a participant. Adds the third state, deferred-with-a-reason, since a two-state protocol forces an agent to lie in one direction. Notes that a stall must be detectable because the human spotted both before either agent did. Adds a review-discipline section — check the enforcement rather than the description, run it against a real machine, a check never seen failing is not evidence, distrust vacuous passes, expect stacked bugs, distrust "inert today", and look at which way unknown resolves. Adds a failure-mode table to pattern-match against, and the git hygiene that bit us, including merge-verify-then-delete, which I got wrong. Closes with session economics, an ordered list of what to build, and the one thing not to automate: agents may coordinate on what is true and must not decide what is permitted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
015e280e5c |
document how to launch the watcher, since the mechanism is what did not transfer
The owner could not convey this to the second agent, who launched it differently and got something that looked identical and did not work. The script was never the hard part; the mechanism is. States the requirement so it survives a different harness — a detached shell process owned by the agent's harness, which exits when it has something to say, and whose exit re-invokes the agent — and notes that dropping any one of those three breaks it invisibly. Then the four wrong ways, each of which looks correct while running. Backgrounding with nohup or & produces a process that polls correctly, detects the push, exits, and never tells the agent, because the harness is not tracking it; I made that exact mistake and caught it only by re-reading my own command. A model-driven interval is functionally correct and pays a full context re-read per tick to learn nothing — the intuitive design, and the expensive one, which is why it is the first thing to warn a new agent about. A loop that does not exit on detection has no path to the agent at all. And per-tick logging is deferred cost that lands all at once on wake. Also records why 30s polling is free in a shell and ruinous in the model, including the five-minute prompt-cache TTL that makes any model-side wake beyond it pay for a full uncached read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e9d0261e87 |
a field report on two agents working one branch
docs/agent-coordination.md states the objective — several agents on one body of work, coordinating with each other rather than through the human — and was written in theory on 2026-08-07. On 2026-08-11/12 it ran for ten hours with two agents and the owner arbitrating. This is what happened, written as evidence rather than proposal. The load-bearing observation is narrower than "two reviewers are better than one": the person who writes the sentence explaining why something is safe is the worst-placed person to notice the code disagrees with it. One agent wrote "a wrong answer here must not happen by accident" and shipped that accident in the same commit; the other wrote a verification script that could not fail on the first one's machine. Neither was careless. Each was reading their own reasoning back and finding that it agreed with itself. Also records what only running found — an installer piped into the wrong shell, a parent directory created root:root, an ACL mask clamped so the file browser could not read a member's home, a chat cwd the member could not enter, ACL entries surviving a chown — all on first executions, all invisible to review. And what the communications channel got right and the five ways its termination rules broke, and why the repo watcher belongs in a shell loop rather than in the model. Names the identity gap as the first thing to build: both agents commit as the owner, so neither the log nor an agent can say who wrote a line. docs/agent-git-identity.md has called that an idea since 2026-08-10; it stopped being one tonight. Also corrects the deprovision spec's status, which still said "not yet run against a real account" after it had been run and verified clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cec8fbe57e |
the acl check could not fail, because sudo drops DATA_PATH
Review of
|
||
|
|
a2f63dc534 |
severing ownership does not sever the ACL, and neither the spec nor the checker said so
Reviewing
|
||
|
|
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>
|
||
|
|
f34d7fef70 | merge: a checker for the deprovision spec, and the trap that makes it pass for free | ||
|
|
35715546e2 |
a checker for the deprovision spec, and the trap that makes it pass for free
scripts/assert-uid-free.sh is the verification half of docs/deprovision-os-account.md, written outside the implementation on purpose: a checker the function calls is a restatement of its own beliefs rather than an audit. Nine checks — passwd entry, uid reuse, both subid files, linger, runtime dir, live processes, files owned by the uid, and files owned anywhere in the freed subuid range. Two modes, because the range has to be captured BEFORE deletion. userdel removes the /etc/subuid entry along with the account, and after that there is no way to ask what range it held — so a checker that only runs afterwards silently drops the half most likely to be wrong. Exercised against green while fully provisioned: eight of nine checks fail, exit 1. A checker that has never been seen to fail is not evidence. And the trap worth knowing before anyone trusts a green result: the subuid check passes vacuously on most accounts. Files get a mapped owner only when a process inside a container runs as a NON-root user; an image whose files are root-owned maps to the member's own uid and leaves the range empty. Measured on green after a night of real use — claude installed, an image pulled, transcripts written — the range check found zero files and passed without testing anything. The spec now says how to build a specimen that actually exercises it, and to watch the check fail on that tree before trusting it to pass on a cleaned one. Docs and a script only; no behaviour change. On a branch, for whoever merges it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
dcaee2fc95 |
keep the three open threads the COMMS channel was holding
The channel was deleted when per-user Claude merged, which was right — it was conversation, not documentation. Three things in it were neither: found while proving the feature worked, understood, and unfinished. The web terminal renders a long URL unreadably. OSC 52 is fixed so "press c to copy" works, which is the path a user is meant to take; the rendering itself is not diagnosed. It matters because first-run login is every member's first five minutes, and the workaround was running claude under tmux on the server and reassembling the URL from a captured pane. Agent sessions do not survive a restart with their identity intact. That one property is behind three symptoms — the crash blast radius, the restart sweep having to skip sessions with no recorded userId, and the stuck "generating" spinner — and documenting them separately invites three separate fixes for one cause. And the ProcessTransport rejection is survivable but still unexplained. Recorded with the log markers that distinguish "the backstop is working" from "it stopped working", since the next occurrence is now evidence in a live process rather than a corpse. On a branch rather than straight onto master, docs-only, for whoever merges it next. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4da82e7f91 |
spec deprovisionOsAccount, from a real teardown rather than from reading the code
Written in docs/ rather than COMMS because COMMS is deleted when per-user Claude lands and this describes a project that starts after it — a spec that gets deleted before it is implemented is not a spec. Everything measured on this host on 2026-08-11. The evidence for why it exists: after deleting a member through the UI, the row was gone and the Linux account, a working login shell, a healthy postgres container, 454MB of home and Docker storage, lingering, the runtime directory and the subuid ranges were all still there. Three things the spec carries that reading the code would not have produced. terminate-user is not a barrier. A member's /bin/zsh -i survived it by three hours, and userdel refuses while a process owned by the account is alive, so an implementation that trusts it works on a quiet account and fails on a member who left a shell open. The subuid half. Rootless Docker storage is owned by MAPPED ids, not the member's uid — postgres's data directory belonged to 231141, not 1002. userdel releases the range and a later account can be allocated it, so a check for "nothing owned by the freed uid" passes while hundreds of megabytes are still owned by the freed range. Verification has to scan the range. And a correction to the order I actually used: sever the data from the uid BEFORE releasing it. The teardown ran userdel first and removed data after, which leaves a window where the uid is free while files still carry it. The irreversible step goes last. Also specified: never userdel -r, preserve-by-chown as the default with destroy opt-in, refuse to release the uid if the sever failed, and do not run anything as the member after terminating — creating a session recreates the runtime directory the step just removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ed52faae02 |
correct two claims about agents that the SDK disproves
docs/per-user-linux-accounts.md carried the reasoning that per-user agents were a large piece of work, and both halves of that reasoning were wrong. The SDK does have somewhere to put a uid — spawnClaudeCodeProcess, documented for running Claude Code in VMs and containers — so a member's turn does not have to become its own process. And the credential claim was backwards: the proxy holds the OWNER'S credential, reading the owner's own ~/.claude/.credentials.json, so pointing a member at it spends the owner's account on the member's turns. The previous handoff had already retracted that one; the doc had not caught up, which is how a retracted claim stays live. Corrected in place rather than deleted, with what was believed and why it was wrong, because the superseded version is the interesting part: the first claim is what made agents look like a later stage than they are. Adds the constraint that actually is out of scope, which the old text never stated: no platform process ever runs as a member, because the sidecar holds POSTGRES_URL and the JWT secret. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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.
|
||
|
|
aaeb3424ab |
the rootless docker fix is proven; correcting the record
|
||
|
|
e4acf19a35 | Merge remote-tracking branch 'gitea/master' into sidecar-app-store | ||
|
|
ea0d2396f7 |
linux accounts use the chosen username, and refuse to take one over
Two changes, and the second is what makes the first safe. The officer_ prefix is gone: a member's account is the username the owner typed, so whoami says who they are and a commit from their checkout is attributed to something recognisable. Measured first — useradd on this host accepts everything validateUsername permits, including dots, hyphens, underscores and uppercase. The prefix was also load-bearing, though, and not for looks. ensureOsUser REUSES an existing account, which is what makes it re-runnable, and that was safe by construction while only we created officer_* names. Unprefixed, adoption becomes the dangerous path: a platform account named root would have found root in passwd, and every runAs for that member would have been a root shell. So adoption now requires the existing account's passwd home to be exactly the home we are about to confine — that is what makes it ours — and any uid below 1000 is refused outright. Verified: root and daemon refused as system accounts, and the owner's own username refused by name with its real home quoted back. Also, the ancestor trap from the first real install. A member's home is under DATA_PATH, which is under the OWNER'S home, and /home/<owner> is 750 on Debian and Ubuntu — so every mode bit on the account tree was right, the directory existed, and the member still could not reach it for want of x four levels up. It surfaced as "ssh-keygen: Could not stat …/.ssh: Permission denied", which points at the wrong thing entirely. firstUntraversableAncestor now walks the chain as the member before anything uses the home, and the error names the directory and the chmod. The dev machine was already 751, and the probe used /tmp, so it never crossed the ancestor that mattered. Worth remembering as a shape of mistake. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0fb9a29e64 |
ssh for a member's linux account, both directions
Inbound and outbound are two keys doing two jobs, and treating them as
alternatives breaks the goal:
inbound ~/.ssh/authorized_keys, from an optional public key the owner pastes
on the create form. Their private half stays on their laptop.
outbound ~/.ssh/id_ed25519, generated in their home, never leaves the machine.
"They pasted a key, so skip generating one" is the obvious simplification. Agent
forwarding covers a human in an interactive session, but a platform-spawned agent
has no agent socket to borrow — so an edge checkout it is asked to commit and push
needs a key that lives on the box. The inbound key is therefore optional and the
outbound one is not.
No linux password, ever: useradd sets none, which blocks password login and does
not block key auth. So "real user, reachable over SSH, no password anywhere" is
the resting state, and the platform password stays the platform's business.
Validation is about line count, not key shape. Every line of authorized_keys is a
credential, so a pasted value with a newline would install a SECOND key silently.
Multi-line refused, a private key refused by name, an options prefix refused.
Every write goes through sudo install: the home is 700 and the member's, so the
service user cannot even create .ssh. install sets content, owner and mode in one
step, and content travels as a temp path so nothing quotes a form value into a
shell. ssh-keygen runs AS the member so the private key is never briefly root's.
known_hosts is not seeded — StrictHostKeyChecking accept-new instead. The Gitea
SSH endpoint is not knowable at create time, and the default setting makes a first
connection prompt, which in a non-interactive agent turn is a hang rather than an
error. accept-new still refuses a changed host key.
The generated public key is stored on the row and shown twice: on the after-create
panel and behind a key button on the user's row. It has an errand attached that
nothing else will remind anyone about — it must be added to their Gitea account.
Verified with a real useradd: .ssh 700 and id_ed25519 600 both owned by the member
and usable by them, authorized_keys byte-identical to the paste, no key rotation on
a second run, and a multi-line paste refused with authorized_keys untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
5c7ceb2283 |
per-user linux accounts, stage 1: the account and the privilege drop
A member gets a real Linux account whose home is the directory the platform already
provisions for them. Nothing uses it yet — this is the mechanism plus the account,
deliberately with no behaviour change, so the file browser and terminal can be moved
onto something already proven.
Bun.spawn silently ignores uid/gid. Verified on 1.3.10: from uid 1000,
Bun.spawn(['id','-u'], {uid: 65534}) exits 0 and prints 1000. No throw, no warning.
Bun's types don't declare the option so typed code can't reach it by accident, but the
runtime accepts it, and a silently absent isolation boundary is the worst outcome this
feature could have. So privilege drops go through sudo -n setpriv, and a test pins Bun's
behaviour — if it's ever implemented, that test tells us we may simplify.
sudo is required for the drop and not because of the uid: --init-groups fails with
"Operation not permitted" for an unprivileged caller even when reuid'ing to its own
account, because setgroups(2) is root-only. --reset-env is what stops the platform's
environment crossing; verified POSTGRES_URL is unset on the far side and HOME arrives
from the target's passwd entry.
Three bugs that only a real run with a real useradd could find:
- chmod after chown fails forever, because chmod needs ownership. Both orderings fail
unprivileged. Both operations now go through sudo, which is what makes it re-runnable.
- a member could read ANOTHER member's home: provisionUserDirs created at the default
umask (755) and only the account being created got confined. An unlistable parent is
no protection when the child is world-readable and emails are guessable. The skeleton
is now created closed, 711 on the account dir and 700 inside.
- platform/.env was 664 and a member's shell printed JWT_SECRET, which is enough to mint
an owner token and bypass every capability check. Now a boot check that refuses to
start with OFFICER_OS_USERS on while any .env in the project root is group- or
world-readable.
Design, the measured results and the staging plan: docs/per-user-linux-accounts.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
f67bb44b7e |
fold the upstream source reading into the assessment
The first pass was written from the running server's own OpenAPI document and live probes. This adds what the source at tag v1.18.16 says, which changes three things. The names are transitional at BOTH ends. session.next.* is the event family of the rewritten event-sourced engine, landed in 1.15.0 (PR #27415); on the v2 branch all 36 events have already dropped the .next. and some are renamed outright — agent.switched becomes agent.selected, prompted becomes prompt.promoted. Those renames are v2-branch only and the 1.x line we run still emits the old names, so the guidance is to code against them but keep one mapping table. The schema package's own AGENTS.md says the V2 suffix is going too. Upstream calls the /api surface EXPERIMENTAL in its own title — "Experimental HttpApi surface for selected instance routes", version 0.0.1 — while /session/* is what the public docs document and is not deprecated. Worth writing down plainly: the internal direction is unambiguous, the external commitment is nil, and we would be building on a surface its authors have not committed to. The SDK is generated from the exact document we probed: the build script runs opencode's own generate and feeds it to hey-api, and @opencode-ai/sdk/v2 exposes the whole /api surface, takes a directory and injects it as both the header and the location query param. That is our hand-rolled SSE reader, both envelope unwrappers, three type sets and the model-id splitting, deleted. Also corrected by reading rather than guessing: permissions v2 is a real contract change (rules, requests and the reply all change shape, and free-text replies are gone) while questions v2 is a pure re-homing with identical fields — so they are not one piece of work. And the durable cursor's replay-then-live is gap-free by construction: it re-reads the database on every wake instead of draining a buffer, with the prompt response's admittedSeq as the first cursor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d86d3ed1c0 |
assess opencode's newer api, and find two live defects while doing it
Tonight's brief was to read everything about "OpenCode API 2.0" and write down what moving to
it would change and what it would buy. Two things fell out of the measuring that are not
migration concerns at all — they are broken in production right now:
The two surfaces are MUTUALLY BLIND. A session created through /api reads as [] on the legacy
GET /session/{id}/message, and a legacy session 500s on GET /api/session/{id}/message. We run
turns through /api since Phase D and read transcripts through legacy, so every opencode
conversation created since 2026-08-10 opens empty — the row carries its title and directory
from the session record, and the transcript underneath it is nothing.
GET /api/session defaults to 50 rows and hands back a cursor.next. We send neither limit nor
cursor, so the oldest sessions silently stop appearing once the store passes 50. The local
store is at exactly 50 today. That is this morning's commit.
On the name: there is no "2.0" in the running server, and "API 2.0" turns out to mean two
different things. The /api/* surface in 1.18.16 has operation ids literally called v2.*, and
we already run every turn on it — so it is not something to adopt, it is something to finish.
OpenCode 2.0 the product is a separate beta (binary opencode2, npm @next) whose docs warn it
may wipe data, and which REMOVES the two durable routes the restart-recovery work would depend
on, in favour of an experimental/ path. Worth knowing before building on them.
Verified by driving a real turn end to end: the durable event log replays from a cursor
(?after=5 returned exactly 6-10, and the SSE at ?after=7 replayed 8,9,10 then held the socket),
which is the answer to the gap Phase B left open. But deltas are live-only BY SCHEMA — the
durable oneOf has 28 members and omits text.delta, tool.input.delta, reasoning.delta,
compaction.delta — so both streams are needed, not one.
Also reproduced a second silent-failure mode with the same signature as the missing credential:
a session with no model, on a serve with no configured default, sits at admitted -> prompted
forever. Our runner only sets a model when one was asked for.
Probes cleaned up after themselves; the session store is back to the 50 rows it started with.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
0a4ff548b9 |
revert the chat tabs and panes work, back to one conversation
Andre asked for zero, not another fix on top. Reverts ec4f06a..7726c9f — the ten commits from "tabs and panes" onward: the tab bar and pane splitting, tab renaming and its page title, the per-server directory picker, the render-loop fix, pane transcript resolution, the send queue, the two socket fixes from the other session, the pane-socket notes, and my own socket-set change from tonight. He is rebuilding from here. Deliberately KEPT: |
||
|
|
907ac46eec |
write down where the remote pane socket bug stands
A pane on a remote server reads fine and never connects its socket. Captured what has been ruled out by direct test — the server accepts that exact key over wss with and without a browser Origin, on the first try — so the next session does not re-derive any of it. The remaining question is client-side lifecycle with several sockets mounted at once, and the first move is instrumentation rather than theory: the console says a close arrived during CONNECTING and does not say who called it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ec4f06a313 |
write down how an agent could commit as itself
Not built, and deliberately so — this is the idea as it stood, with the spawn path read at
|
||
|
|
45e3e86d72 |
write down what to test, and what is most likely broken
The serve is the only path now, so this is not a comparison against a fallback. Split by what I have actually driven end to end versus what probes cannot answer. The second list is the real testing: resume from history (never exercised against the serve, and my pick for most likely broken), an idle session, a sidecar restart mid-turn, an officer restart mid-turn, and two conversations at once — that last one because the live event stream is global and a wrong sessionID filter would splice one conversation into another. Known gaps are listed so they do not get reported as bugs, and the one silent failure mode with a single cause — a turn producing nothing at all — points at the credential line from boot, which I have chased twice already. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a058cbb3fd |
inject a mid-turn message instead of replacing the turn
Phase C server half, and a real flaw in phase B. On the subprocess path a second message could only supersede — kill the process, start again, lose the turn — because opencode run has no input channel. The serve takes another prompt into the running turn, so a message arriving mid-turn is handed over with delivery steer and the existing turn is left exactly as it is. Keeping the same turn object is the load-bearing part. Phase B retired it and registered a replacement, which stops officer routing events the serve is still producing while the serve carries on regardless: output goes nowhere and the turn looks hung. Verified end to end through the chat socket — sent a count to 50, injected a change of plan eight seconds in, and BANANA INJECTED came back inside the same turn with deltas streaming throughout. No client change was needed. Officer composer already sends while generating; the difference is only what the sidecar does with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
feb9010097 |
phase A: map the serve event stream, routed nowhere
The mapping half of the serve migration, written and pinned before anything depends on it,
so the switch-over is not also the moment the parsing turns out to be wrong. Nothing routes
through this — turns are still opencode run subprocesses, and the claude path is untouched.
The finding that matters: the serve publishes each turn TWICE, and reading the wrong one
makes it look like it cannot stream at all.
/api/session/{id}/event?after= durable, per session, replayable, durable.seq on every
event, whole values only, NO deltas
/api/event live, GLOBAL, ephemeral, carries text.delta and
tool.input.delta, no cursor
Same turn: 13 events durable, 21 live, the difference being 3 text.delta and 5
tool.input.delta. I probed the per-session one first and nearly recorded "no streaming" as
a fact — it would have removed the main reason to migrate. The split maps exactly onto what
officer already does for claude: durable to chat_session_events, live to UI deltas. The cost
is that the live stream is global, so a consumer must filter on sessionID.
tool:start is emitted on tool.called, not tool.input.started, because only tool.called has
the resolved input object — the input arrives as JSON fragments ({"comman) and a tool row
rendered with half-parsed arguments is worse than one that appears a moment later.
step.ended with finish tool-calls is a step boundary MID-turn, not the end of the turn, so
nothing terminal is emitted for it. Treating it as the end would cut every tool-using
conversation in half.
Fixtures are verbatim captures from 1.18.16. Replaying both real streams through the mapper
reconstructs the turn identically from each, with the reassembled deltas exactly equal to
the committed text and identical cost, and zero unrecognised events.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
38ebe168f0 |
retire two phantom gaps, and plan the migration
Crash-recovery state is not a gap: state:sync goes to the proxy capability and carries proxySecret, and syncState/getCachedState have no callers at all. The row compared opencode against a mechanism officer never consults. The real recovery story now exists and is better — a sidecar restart stops in-flight turns and writes the reason to chat_session_events. Identity is deferred, not forgotten: TODO.md already records it, and chat is kind execution, which the grants API refuses to share at any level, so no member can reach it. Also adds the serve migration plan, written while the facts are fresh and nothing is on fire. It leads with the five things that will bite whoever implements it — per-request location, the data wrapper, delivery defaulting to steer, silent failure on an unconnected credential, and the session.next event names — because none of them are in the API docs and each cost time to find today. Phased so the old path stays one config flip away, and so warm-session lifetime (idle GC, orphan adoption, the supersede race) is imported deliberately rather than discovered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
86fd03b35b |
say a missing opencode binary out loud instead of hanging
Bun.spawn throws on a missing or non-executable binary rather than resolving to a failed process, and that throw escaped runOpenCodeTurn entirely — past the bookkeeping, out of the sidecar command handler, with no opencode:event ever emitted. The browser sat on a spinner nothing could end, because the code that ends turns had not been reached. A wrong OPENCODE_BIN is the ordinary way to get there, so the message names the path it tried: that is the difference between a fix and a debugging session. Also records that messageCount is not a gap. SessionList renders an OpenCode badge in place of the count for those rows, so the hardcoded 0 never reaches a screen, and computing a real one would cost an HTTP call per listed session — the session record has no count field — to populate something nothing shows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ecb9025f8f |
the fork blocker was a missing credential, not an upstream bug
Andre said his terminal opencode reaches paid zen models and suggested it was simply not set up here. Correct, and my second wrong call on this page. The new /api pipeline has its own credential store — /api/integration and /api/credential — separate from auth.json, which is what the CLI, opencode run and the legacy /session surface read. Ours had none connected, so it fell back to what needs no credential: the free tier. One POST to /api/integration/opencode/connect/key fixes it, and it survives a serve restart. sonnet and haiku both run on the new pipeline now. The tell I had and did not use: the configured default is big-pickle, and a session with no model ran on ling-3.0-tiny-free INSTEAD of the default. A pipeline ignoring its configured default cannot use it — a credential symptom, sitting in /config/providers the whole time. So steer, queue, interrupt and the resumable per-session SSE are all available with real models. alpha needs the same one-time connect, and the sidecar should do it at boot rather than depend on someone having run it by hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f0aa6dbf4b |
record that images are done and were never fork-gated
Bucket 1 lists them as No, and phase 4 put them behind the migration. opencode run takes --file, so the path we already use carries them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a41abb4b0f |
characterise the fork blocker: only free models run
Not sonnet, and not variant. Swept models through the new pipeline: every -free model runs, every paid one silently does not — haiku, sonnet and codex-mini all never start. Ruled out: variant (sonnet advertises low/medium/high/max and echoes back an invalid "default", which looked like the answer and was not — setting high explicitly also never ran); credentials (zen key in auth.json plus ANTHROPIC_API_KEY); and the sidecar environment, since the same process runs sonnet fine through opencode run. So the new pipeline does not resolve paid-model credentials and says nothing, while run and the legacy path authenticate fine. Upstream bug in an in-progress pipeline, not our config. The fork stays blocked, but precisely: steer and queue are proven, and the day a paid model runs there the migration is worth doing immediately. Re-run the sweep after each upgrade. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b197c2aeba |
app store: settle the lifecycle — disable stops the container, uninstall keeps the schema
Disable now stops the container as well as the sidecar. There is no reason to leave Immich holding memory while Photos is switched off. For mode 'existing' there is no container of ours, so disable is only the sidecar. Uninstall stops both, removes the containers, and deletes the install row. It does NOT drop the sidecar's tables — pushing back on "maybe db schema too" for the same reason volumes are kept, because it is the same category. Music favourites, the Jellyfin server registry, photos configuration and saved connections are real data, and someone uninstalling Photos is saying "stop running this", not "forget which albums I favourited". Keeping them also makes reinstall a RESTORE: uninstall in June, reinstall in August, and the configuration is still there. Dropping the schema would hand back a blank service that looks subtly broken to someone who remembers setting it up. An unused table costs a row in information_schema and nothing else. Also removes a line left stale by the previous commit, which still said the user chooses disposal at uninstall time. There is no such choice any more, and a doc that describes an option the code does not have is how the option comes back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
936b96a36e |
app store: uninstall removes containers and never data
There is now no uninstall option that deletes data, rather than a careful one that does. A user uninstalling a sidecar is saying "stop running this", which is not the same sentence as "delete my photo library", and for Immich or Jellyfin getting that wrong once is unrecoverable. No confirmation dialog makes it a good default. So: `docker compose down` without `-v`. Containers and networks go; the service directory and everything under it stays exactly as it was. The bind-mount convention already makes this hard to get wrong, which is worth noting because it means the safety is structural rather than a rule someone has to keep following. Data lives on the host inside the service directory, so `-v` — which only removes NAMED volumes — could not delete it even if a future change added the flag back. `mode: 'existing'` has no disposal question at all: we did not create that service, so uninstall removes our sidecar and our rows and touches nothing else. Reclaiming disk becomes its own feature later, with the sizes in front of the user — "Photos is using 340 GB, delete it?" — as a deliberate act rather than a checkbox inside an uninstall flow. Removed two stale `down -v` references that survived the first pass, one in the schema comment and one in the design doc's table. Leftovers like those are how a rule becomes permission again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ee38257856 |
app store: make member provisioning a mechanism, not an install-time loop
The owner installs, but a server may already have members, and a member added next month needs the same work. So the unit is (service × member) reachable from two triggers — install a service, provision existing members; add a member, provision installed services — rather than a loop inside the installer. Only handling the first works on day one and rots. No new table. A member is provisioned exactly when they hold a service_connections row: their own credential, url NULL, inheriting the instance from the owner's. That schema anticipated this before this existed, and a second record of the same fact would only be able to disagree with the first. Three outcomes, declared per catalogue entry so the installer never special-cases a service. `accounts` is fully transparent. `none` is a single-tenant daemon with nothing to do — filtered before the provisioning loop so callers can tell "nothing to do" from "did nothing", which look identical at a call site and matter when someone is asking why a member cannot see a feature. `invite` is not a weaker `accounts`, it is the correct outcome: Vaultwarden derives its encryption key from the master password, so a credential we could mint would mean a vault we could read. Transparent right up to where being transparent would be a defect. The per-service work is an interface implemented beside each sidecar rather than a switch in core — a central function growing a case per service is what would stop any of this shipping from its own repository. Implementations must be idempotent, since both triggers can fire for the same pair and a duplicate account upstream is not ours to undo. Deprovision is optional and defaults to leaving the upstream account alone: deleting an Immich user deletes their photos. Written assuming the vault's multi-user adaptation has landed. Today /api/vault is owner-only by an explicit ownerGate, so a member is refused before Vaultwarden is reached — verified, and out of scope. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
890f57a7a6 |
app store: compose templates and their setup scripts, with two proven end to end
Each provisionable service gets a directory holding a compose template and a setup.sh. Deliberately the shape a sidecar needs once it lives in its own repository: metadata, compose, setup script, schema. The contract (templates/README.md): answers come from the ENVIRONMENT, so the web form fills them in and a person on a VPS is prompted only for what is missing, and only on a TTY — one script for both, not two code paths. Idempotent, writes only inside its own directory, streams progress on stdout (the installer pipes it to a terminal panel), and returns results as OFFICER_RESULT_<KEY>= lines so nothing has to scrape a log. House conventions throughout: relative bind mounts so data sits beside the compose file rather than hiding behind `docker volume inspect`, containers running as the installing user so downloads are not root-owned, loopback-only ports unless the service's whole job is inbound connections, and no external networks — the owner's own composes attach to an `nginx` network that a fresh VPS does not have. Transmission verified end to end on this machine, on non-conflicting ports, then torn down: renders, starts, waits, reports. Its health check accepts 409 because Transmission rejects the first request by design — only-200 would have waited out the full timeout against a working daemon. Re-run produced exactly one container, and files landed owned by the user rather than root. Vaultwarden covers the case where we GENERATE the credential rather than asking for one. An existing token is reused, never rotated, because rotating during a resumed install would lock the owner out of the admin page. The Argon2 hash has its `$` doubled or compose interpolation mangles it. The token is not returned to the platform at all — the vault sidecar proxies the Bitwarden protocol and never needs it, and a secret we do not hold is one we cannot leak. Corrects the design doc, which assumed provisioning always knows the connection. Three shapes: we set the credential, we generate it, or a human must mint it in the service's UI afterwards (Immich, Jellyfin, Memos). The third makes "provisioned and running but not yet connected" a real state rather than a failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7359867f7f |
app store: check the host before writing anything
An install that discovers a missing dependency halfway through has already made a directory, possibly started a container and written a row, and then has to unwind — leaving the user with something that neither works nor uninstalls. A 30ms check first is worth most of that. Verified while writing this: nothing in scripts/ installs Docker, and nothing checks for it. setup-dockers.sh invokes `docker compose` with no preflight, so a fresh host without Docker fails partway through setup with a bare "command not found". Recorded in the design doc rather than fixed here — the intended fix is a setup.sh per sidecar, which is also what a sidecar needs once it ships from its own repository. `docker compose version` is the probe, not `docker --version`: the latter passes with a dead daemon, which is the failure people actually hit. "Not installed" and "daemon unreachable" are reported separately because the remedies differ. Checked per MODE, not per entry. A host without Docker can still install Photos by pointing at an Immich somewhere else; refusing the whole entry is the over-strict check that makes people work around the installer instead of using it. Dropped `requires: 'docker'` from the catalogue type. Needing Docker is exactly "this entry can provision", which `modes` already says, so declaring it twice invites the two to disagree. Derived by needsDocker instead, and a test asserts the derivation matches every entry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
654cb10711 |
app store: put provisioned containers under the officer root, not the user's
The install layout a machine should have, seasoned owner or not:
~/officerdev/
platform/ the app
data/ DATA_PATH
dockers/ services the app store provisioned
capabilities/ the file-based item store
One root, everything under it. OFFICER_ROOT derives from DATA_PATH rather than being a second variable
that has to agree with the first.
Deliberately not `~/dockers`, where a seasoned user already keeps their own estate — 47 services on this
machine. That separation buys two things. Containers the app store created are distinguishable from the
user's own structurally, rather than by a naming convention we would have to enforce and they could
break. And we never reason about someone else's compose files: the store does not scan, adopt or modify
anything outside its own directory.
That also simplifies "I already have one of these" — it is answered by the user giving a URL, never by
us finding a directory and guessing whose it is. An earlier draft had the installer adopting existing
directories, which meant reading, and potentially writing over, services Officer did not create.
This development machine predates the convention and derives an ugly-but-correct path, since the project
sits inside ~/dockers/officer.dev. Still isolated, still one root. New installs get the clean shape.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
fc48a572d2 |
app store: the catalogue, the install-state table, and what phase 0 must not foreclose
First slice, on a worktree branch so none of it touches the tree the live server runs from. `sidecar_installs` — server-level, no userId, because a sidecar is one process serving the machine. That is the line that keeps the model coherent for several users: installed is server-level and owner-only, configured is per user in service_connections. A member can use Gitea without being able to install it or point it somewhere else. `installed` and `enabled` are separate because they answer different questions, which is what gives the reversible middle ground: disable stops the process and keeps container, config, schema and data. `completedSteps` makes install resumable rather than merely retryable — the failure mode being designed against is a half-installed service that neither works nor uninstalls. The catalogue is data, not code: no functions, no compile-time coupling, because the same shape has to arrive as JSON from marketplace.officer.dev later. Its test pins it to the real estate — it offers exactly the processes the light profile excludes, names processes that exist, and claims capabilities that exist. That last check earned itself immediately: it caught `vault` (no capability at all — it is EXEMPT because Bitwarden clients carry a Vaultwarden bearer, not a platform JWT) and `notify` (which does have one, where I had written null). Docker templates follow the convention already in use across 47 services in ~/dockers: a directory per service, compose inside, relative bind mounts so data sits beside it, USER_UID/USER_GID as the owner. An existing directory is evidence of an existing install and must be adopted, never overwritten. Records what Phase 0 must not foreclose: a remote marketplace, sidecars moving to their own repositories, and third-party plugins — including the note that catalogue.test.ts pins Phase 0's invariant rather than the design's, since that relationship inverts once sidecars leave this repo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |