baa2d29fa488f4beb9ab9b077cdb5f8c5e93ff29
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
baa2d29fa4 |
read the probe off stdout by position, and renumber comms
host's finding on
|
||
|
|
45df9aaa20 |
review 288679af: the sudo fix is right, its markers collide with the paths
The one-call change is correct and I confirmed the effect. One latent defect it introduced. claudeLoginState decides by substring on `probe.out`, and asMember returns stdout and stderr CONCATENATED — while `bin` is a substring of .local/bin/claude and `cred` of .credentials.json, both of which are passed as arguments. So anything writing either path to stderr flips the flag. Demonstrated here against green with neither file present: `sh -xc` traces the two paths and both booleans come back true, claiming a member is signed in when they have never logged in. Not live — the happy path measures empty stdout and stderr and the correct false/false — but it fails unsafe and is one debug flag away. Uppercase markers do not fix it: a trace echoes the script, so the literal lands on stderr too. The channel is the problem. Suggested stdout-only with a positional two-character answer, keeping stderr for diagnosis but out of the string being matched. Verified from the request list: the pertento host key matches the live server AND the known_hosts every push of mine has used for hours, so first-use acceptance was correct; both chat gates still up and spawnClaudeAsMember imported by zero files; 53 tests pass here, matching their count. Items 2 and 3 need `pm2 restart officer` and a provisioned member, which is outside what the owner scoped to me. Flagged as not-done rather than silent, and referred back to the owner along with the two questions that are theirs: who owns the parked items, and wire-first versus verify-first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
288679af09 |
one sudo call for both agent-status answers, not two
host's finding on
|
||
|
|
f4dc46d67a |
review fb2c5c28..6b7aad91: both guards fire now, one aggregate cost noted
Read |
||
|
|
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> |
||
|
|
06bfcf9556 | Merge remote-tracking branch 'pertento/sidecar-app-store' into sidecar-app-store | ||
|
|
2bd96a9a98 |
tell a member why their agent is not working, instead of 403
|
||
|
|
fb2c5c28cb |
correct my own advice: the subset check cannot fire either
The inversion I suggested replaced one dead check with another. `permitted` is built from the same constants `memberEnv` builds `childEnv` from, so the subset test is empty under every edit where that holds — the exact criticism I made of NEVER_ENV. Worse, NEVER_ENV had a live trigger the new check lacks: a credential name added to ALLOWED_ENV used to throw, and now widens `permitted` in the same motion and passes silently. That is the realistic future edit, and it is the one now unguarded. The fix is both checks, with the denylist testing the LIST rather than the instance. Also verified here: Anthropic's installer puts a symlink at ~/.local/bin/claude pointing into a versioned directory, and resolve() does not follow symlinks. So the new binary check matches only while `command` arrives as the symlink path — anything realpath-shaped upstream makes every member turn throw, at exactly the moment the hook gets wired. Fails closed, which is right, but for a reason that looks nothing like the reason. Signing as `host` from here on, at the owner's request, to tell the two ends of this channel apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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
|
||
|
|
6cd462caf6 |
close §4: officer_jg has no users row, and the adoption rule held
Queried the two rows the handoff asked for. There is no `users` row for `officer_jg`, and ids 2-4 are absent, which tells the whole story: an earlier row for jg@pertento.ai under the `officer_`-prefixed naming got a Linux account at uid 1001, the row was deleted without `userdel`, and the re-created account correctly refused to adopt it and took uid 1002. The home is derived from the email, which never changed — hence two accounts, one home. So this is the delete path, not a bypassed adoption rule, and it is observed rather than theorised. Inert today: the home belongs to green and its ACL names only pastilhas and green, so officer_jg cannot read it. The live hazard is uid 1001 going to the next member, which is what deprovisionOsAccount and its chown to the service user would close. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f09385c789 |
reply from the live server: the bind mount runs, and 711 is not what fixed it
Answers §1 of the per-user-accounts handoff and reviews the per-user-claude one.
A bind-mounted postgres:18-alpine starts, initialises and stays healthy under green's
rootless daemon — so 3bea46f's open question is closed. Two corrections though.
The mechanism in
|
||
|
|
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> |
||
|
|
701933d30d |
commit and push when it goes wrong too
The instruction was standing and nowhere in the repo, so every session started by waiting to be asked. Written down because the reason is not obvious: a branch held back because it is unfinished, untested or a dead end is exactly the branch whose history is worth having. A reverted commit and its message explain why an approach was abandoned; a quietly discarded attempt teaches the next person nothing, and they will try it again. The obligation that comes with it is saying what state the work is in — in the message, and in COMMS/ when another agent will pick it up — rather than letting a clean commit imply it is finished. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
48ed171b38 |
point CLAUDE.md at COMMS, so a new session finds it without being told
The channel is only useful if it is read, and relying on the owner to remember to say "check COMMS" in an opening prompt puts the mechanism back where it started. CLAUDE.md is loaded automatically, so the pointer belongs there: what the directory is for, that newest date wins, and which streams exist. Also states the split it is easy to get wrong — durable reasoning in docs/, coordination in COMMS — and that a spent handoff should be deleted rather than left to be mistaken for current. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
10fe3ffe65 |
COMMS/sidecar-app-store: a tracked channel between agents
Findings were being relayed through the owner by hand, from memory, at the end of long sessions. A file survives a context window and carries its reasoning; a message does not. The untracked COMMS/ at the workspace root stays what it is — state about one machine at one moment. This one is in the repo because any clone should carry it. First handoff covers what I would otherwise have asked the owner to pass on: the bind-mount container test I could not run here and how to retrofit green, the setup-dockers.sh PG18 layout left deliberately alone, the terminal replay bug and the deprovision/uid-reuse hole with a proposed fix, the shared-home question I cannot answer without the passwd and users rows, and the four things most likely to surprise a reader — bootstrap-only default grants, chat grantable but refused, Bun.spawn ignoring uid, and members never getting the owner's anthropic proxy. The README states the convention: dated files, verified separated from assumed, name lines, reply in a new file rather than editing someone else's, and delete a handoff when it is spent. Durable reasoning goes in docs/ or next to the code — this directory is for coordination, not for the record. 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.
|
||
|
|
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> |
||
|
|
aaeb3424ab |
the rootless docker fix is proven; correcting the record
|
||
|
|
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> |
||
|
|
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> |
||
|
|
e4acf19a35 | Merge remote-tracking branch 'gitea/master' into sidecar-app-store | ||
|
|
4d4a253f72 |
terminal runs as the member; chat is grantable and still refused
TERMINAL is confined now, and the shell is genuinely theirs. The pty sidecar spawns it through sudo setpriv as their own account, in their own home, with the platform's environment cleared. Verified end to end against the sidecar's own socket: id -u 1001, not 1000 file the shell wrote owned by ptyprobe ps -o user=,args= ptyprobe /bin/zsh -i env | grep -c POSTGRES 0 osUser and home are resolved in upgradeWs from the authenticated account, and whatever the browser sent under those names is DELETED first. The bridge forwards the query string to the sidecar untouched and the sidecar starts a shell from what it finds there, so trusting the client for either would let a member ask for the owner's uid in a query parameter. node-pty does support uid/gid, unlike Bun.spawn, and they are deliberately unused: they set the ids without applying the account's groups or resetting the environment, so the shell would keep the owner's groups and everything Bun loaded from .env. Also closes the pty identity blindness in TODO.md. Sessions record whose they are, list and kill scope to the caller, and 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. Measured: member killing the owner's session -> ok:false, owner killing it -> ok:true. CHAT is confined so the owner can grant it and the route resolves, and both execution doors refuse a non-owner: the router wholesale, and the socket in server.tsx. The agent has not moved — the SDK spawns claude itself with nowhere to put a uid, and every transcript path resolves through the owner's home, so a member would read the owner's session list and run an agent as the owner. Reads are refused too, because listClaudePwds returns the names of the owner's projects. A deliberate, temporary gap at the owner's request: permission and route now, function when a turn can be spawned under runAs with the member's own HOME. Both guards say so, and the registry test names them so a future edit cannot move one without the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
eda004a46d |
a naked platform does not describe what it does not have
Reversing my own call from an hour ago. I built the denied-route screen to EXPLAIN the absence — "Music is not installed", with a link to the app store — and argued a redirect erases what you asked for. The owner's correction is the better principle: a server should not know about a sidecar it does not have. Explaining Music is the app describing a feature that, as far as this install is concerned, does not exist, and it leaks the whole catalogue of what could be installed to any member who types a URL. So a denied path is now indistinguishable from an unknown one: redirect home, the same answer App.tsx's path="*" already gave. One behaviour for a member without a grant, an owner without the sidecar, and a typo. Nothing disclosed. The Permissions screen loses both explanatory blocks for the same reason. One listed every capability whose sidecar is absent — a catalogue of uninstallable features presented as a permissions decision. The other described chat, tasks, the desktop and the wallet as "not grantable" to an owner who may have none of them installed. `notInstalled` is gone from the API too, not just hidden in the UI. What is on that screen is what this server can actually do. Still short of what the owner described, and worth naming rather than implying otherwise: routes are DECLARED in App.tsx for every screen and this hides the ones that should not resolve. The end state is routes REGISTERED from the manifests of installed sidecars, so an uninstalled feature has no route to hide. The manifests already exist and the dock is already built from them; the router is not, yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b4f88ec161 |
routes refuse at the route, and a new home is empty
Three things, from a member sitting on /music with no music capability on a server with no music sidecar: an empty library, and 403s in the console. PERMISSIONS AT THE ROUTE. `canVisit` filtered the dock and nothing else, so the tile was hidden and the route was wide open — typing the path, following an old link or restoring a tab rendered the screen anyway. RouteGate now wraps every screen in one place, inside the error boundary. It does not redirect. Sending someone to `/` erases what they asked for and reads as a bug: they clicked Music and landed on Home. It says why instead, and the URL stays put so a reload after installing the thing just works. And it says which of the two reasons applies, because they need different screens and send the reader to different places. `not-installed` is a fact about the SERVER — the owner gets a link to the app store. `not-granted` is a fact about the ACCOUNT, and only the owner can change it. Presenting either as the other sends you looking in the wrong place. ROUTES FOLLOW THE SIDECAR. Free, once the above exists: `deniedRoutes` already covers "held but its sidecar is not installed", so an uninstalled feature has no tile AND no screen. The dock, the Permissions list and the routes now agree because they read one answer. NO MORE SEEDING. Downloads/Documents/Music/Videos/Pictures are gone from both places that made them — the member's provisioning and, older and worse, `/ls`, which created folders in somebody's home as a side effect of LOOKING at it. A listing that invents its own contents is a listing you cannot trust, and the platform has no standing to choose a person's folder layout. A new home is empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4b058a6703 |
fix the sign-out reload loop I shipped an hour ago
The 401 handler ended with location.replace('/'), guarded by "unless the path starts
with /signin". There is no /signin route — the sign-in screen IS path="/". So every 401
on the signed-out landing page navigated to the page it was already on, fetched again,
401'd again. A hard refresh loop with no way out of the tab.
The reload was never what fixed anything: useAuth already renders the sign-in screen
when there is no token. It only existed to drop a stale query cache. So it is now the
last thing attempted and bounded three separate ways, any one of which breaks a loop
alone:
1. no token -> return. A 401 while already signed out is expected, not a revocation.
This one alone ends it, because a reloaded document has nothing left to clear.
2. once per document, module flag.
3. once per tab, sessionStorage marker — which also covers a host that re-injects the
token on every load, where clearing storage cannot help and guard 1 never fires.
Anyone stuck in the loop from the previous build: localStorage.clear() in the console.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
d3bed0add9 |
the file browser can actually read a member's home, and plans is gone
"This folder is empty" was a lie. The five seeded directories were sitting there and the platform's readdir raised EACCES: a member's home is 700 and owned by them, which is correct for a shell and locks out the file browser, which runs inside the platform process. /ls caught the error and returned an empty listing, so a refusal looked exactly like data. Two doors, two boundaries, and that is the point rather than a compromise. The terminal and the agent RUN AS the member and the kernel is the boundary there. The file browser acts on the member's behalf from inside the platform, which already applies its own containment and is the owner's process on the owner's machine — it can read anything via sudo regardless. Giving it access describes who is doing the work. Done with named POSIX ACLs, because it has to hold in BOTH directions: a file the platform writes must be editable by the member and vice versa. Mode bits cannot say that — whichever party is neither owner nor group lands in "other", and widening "other" opens the home to every account on the box. A shared group fails the same way, since both parties would have to be in it and that puts every member in a group that can read every other member's home. Two named entries plus `d:` defaults grant exactly two users and are inherited by whatever either side creates, whatever their umask. Verified: platform lists the home, member edits a platform-written file, platform edits a member-written file, and a SECOND member is refused on both ls and cat. /ls now distinguishes EACCES from a missing directory. An empty result is data and must never be how a refusal looks. acl joins the core packages in setup.sh — the alternative is an account that provisions and then cannot list its own home. Also: the file browser's own useTasks/useAgents fired /tasks, /agents and both category endpoints on every render, which is where the last four 403s came from — they are the context menu's Run Task and agent submenus, execution-only. Gated. And plans is deleted: router, screen, routes, dock tile, hook, page title and its capability. It read markdown from <repo>/plans, which does not exist. Fresh-install Permissions is now Files alone, with Terminal to come. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6b4fed68fd |
gitea leaves the baseline and becomes an app-store install
It was in both light profiles on the reasoning that it fronts a REMOTE instance and so needs nothing installed locally. That is true and it was beside the point: a baseline process appears in the dock and in the Permissions screen whether or not anyone ever gave it a URL, so a fresh server offered to grant members access to a Gitea that did not exist. "Is Gitea here" had two answers that could disagree. Now it is `existing` mode with a URL and a token, like any other remote service, and the one place that says whether it is here is the install row. No compose template and no `provisioned` mode: Gitea is always something the owner already runs, and offering to spin one up would mean owning its migration, backup and upgrade story. members: 'none' — not because Gitea is single-tenant, it is the most per-user service in the catalogue, but because there is nothing for the INSTALLER to do. The owner's connection carries the instance; each member adds their own access token from /gitea and acts only as themselves upstream. A provisioner would need an admin token and would mint credentials on their behalf, which is more authority than this needs. The catalogue test already pinned "the store offers exactly what light leaves out", so removing it from the profile is what forced the entry to exist. Both light profiles changed together — the mac one carried the same comment and the same gap. Permissions on a fresh install is now Files and Plans. Plans stays because it reads the platform's own shipped markdown from <repo>/plans, not anyone's disk, so it needs nothing installed and exposes nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e393d0f5c2 |
a member's screens render, and the shell stops asking for things it cannot have
Three findings from granting Files to a role and signing in as the member. THE BLANK SCREEN. WorkspaceView returns null until workspace.isLoaded, and isLoaded was the success flag of GET /api/dashboards — which the `dashboards` capability gated. So a member with files granted got a completely blank Files screen and no request to /api/file-browser at all: the panel never mounted. Terminal, Chat and every other workspace screen were the same. /api/dashboards is not a feature. It is the per-user key-value store where every screen keeps its layout, entirely `personal`, every row keyed to the caller. Gating it does not restrict an account, it breaks it — which is the definition of `core` at the top of the registry. Moved there. And the failure mode was wrong independently: `isLoaded` now covers a failed fetch as well as a successful one, with `loadFailed` for the difference, so a screen that cannot remember its layout still renders with defaults instead of showing nothing and explaining nothing. THE STRAY REQUESTS. Six shell-level queries gated on isAuthenticated but not on capability, so a member's first paint fired 403s at /server-settings/settings, /jobs/counts (every three seconds, forever), /chat/models, /plans, /music/now-playing and the chat access policy. Each now checks the capability it needs. JobsIndicator and RescanButton also render nothing without `tasks` and `items` — the header was offering two links to a screen the member cannot open and a button that would 403. THE PERMISSIONS SCREEN. It listed all fourteen app capabilities on a server where none of their sidecars are installed. Offering to grant Photos on a machine with no Immich is not a permission decision. It now shows only what is installed, lists the rest as "nothing installed for these yet" so their absence reads as a fact rather than a bug, and marks confined rows as needing a Linux account. Fails open on a degraded read. Found while checking that: the headscale catalogue entry claimed only the `headscale` capability, but the same sidecar also serves `vpn` — a member enrolling their own device — so vpn was never subtracted. Hence `alsoServes`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2c9d4e55aa |
retry a linux account in place instead of deleting the person
POST /users/:id/provision-linux, and a terminal button on each user row. One
operation covering three needs that were all previously answered by "delete the
account and make it again":
backfill an account created before the feature existed, or while the host was not
set up for it
retry the first attempt failed for something since fixed — the traversable
ancestor chmod being the one everybody hits once
re-key replace authorized_keys with a new public key
Deleting to redo a retryable side effect throws away the password, the dashboards and
everything else keyed to the row.
The provisioning block moves out of create-user into provisionOsAccount, shared by
both entry points for the same reason app-store/members.ts is shaped that way: two
moments, one piece of work.
Found by testing the retry rather than the create: provisionUserDirs re-chmods every
directory including home, and home belongs to the MEMBER after the first successful
run — chmod requires ownership, so it threw EPERM and took every retry down before it
started. Those chmods are now a default for directories being created, not an
assertion about ones that already exist; os-user.ts sets the home's mode through sudo
and is the authority for it.
The route answers 200 with the error in the body, because the interesting cases are
partial: "the account exists and is confined but the keys failed" is not nothing
having happened, and the row shows both halves.
Verified end to end: blocked ancestor reports the chmod and leaves osUser null, the
retry after that chmod succeeds and records the row, and a re-key replaces
authorized_keys without rotating the outbound key.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
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> |
||
|
|
0281ca62d2 |
a deleted or blocked account loses its session on the next request
Reported from two browser windows: an account deleted from the dashboard survived a page refresh in the other one. Two independent halves. Server: userMiddleware looked the account up, then read the result as `dbUser?.passwordChangedAt` — so a DELETED account fell through the optional chain and the request proceeded on a token that is still cryptographically valid, for up to the full 30 days. `status` was the same hole from the other direction: signin refuses anything that is not Active, but nothing rechecked it afterwards, so marking someone Blocked did not end the session they already had, which is exactly when you would be doing it. Now the account must exist and be Active on every request. Client: nothing reacted to a 401 at all. onError fed the bug-report form and stopped there, so the window kept rendering off cached React Query data. A 401 now clears every storage key createClient reads and returns to the sign-in screen. /auth/ is exempt because a wrong password is also a 401 and reloading the form would look like a crash. window.officerBearerToken was declared non-optional, which made "there is no token" unspeakable. It has always been one of five sources, any of which may be absent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4d513c0e13 |
files, for a member, in their own home
Introduces a fifth capability kind. `files` was `execution` — never grantable, because it meant the OWNER'S filesystem. It is now `confined`: execution-shaped, but the kernel enforces the boundary because the account has its own Linux user, its own home, and no permission above it. The rule that makes `confined` mean something lives in authorize.ts, once: a confined grant is DROPPED for an account with no osUser. So "granted but unconfined" resolves to no access rather than to the owner's home — which is what it would otherwise resolve to, since getOwnerHomeDir ignores the email it is handed whenever HOME_DIR is set. One rule covers the HTTP routes, the websocket doors and the dock, instead of each router remembering. resolveHomeDir(userId) is the new seam and it reads the row rather than the token, for the same reason authorize.ts re-reads role: provisioning a Linux account for an existing member has to take effect on the next request, not in thirty days. The file browser resolves it in middleware and puts it on ctx user, because getRootDir is called from fifteen places in that router. Making it async would have meant editing fifteen call sites, and the cost of missing one is serving the owner's home to a member. Now a handler cannot run without the answer. Two things a real run caught: - /ls seeds Downloads/Documents into the home as the service user, which is EPERM against a 700 home owned by the member — it took the whole listing down. Seeding is now best-effort there and happens at provision time instead, as the member. - .unique() on os_user made db:push ask whether to TRUNCATE users, which is unanswerable non-interactively. uniqueIndex instead, per databases/CLAUDE.md. Verified: a member without a Linux account is refused by name; with one, resolves to their own home and NOT to HOME_DIR; the owner still resolves to HOME_DIR; and every .. escape is refused while an absolute path is rebased under the root. Terminal is still execution — that is the next stage. 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>
|
||
|
|
69a31051ac |
the owner can create accounts
POST /api/users plus an Add-account form in Settings > User management. Until now createUser had one call site — bootstrap, gated on an empty user table — so every non-owner account anywhere had been inserted into Postgres by hand. Created accounts are Active. The column defaults to Unverified and signin refuses anything else with a bare UNAUTHORIZED, which is exactly what made the hand-INSERT route look like a wrong password. Also closes a hole found while reading the write path: a second Super Admin was storable. The CHECK constraint pins user 1's role but cannot see other rows, and getOwnerUser() was LIMIT 1 with no ORDER BY, so two holders would have made "who owns this server" a question the query plan answered — and that answer feeds the agent sidecar's identity, vault access and origin scoping. Both write paths now refuse the role and getOwnerUser() orders by id. USER_DIRS and provisionUserDirs move into data-path.ts so the create handler and scripts/provision-user-dirs.ts cannot disagree about what an account's skeleton is. 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>
|
||
|
|
801eba9284 |
say which harness owns a chat row, on both kinds
The list is merged from two stores and only OpenCode rows were badged, so Claude was marked by the ABSENCE of a badge — legible only if you already knew the list mixes two harnesses. Both carry one now, and since `harness` is absent on older Claude rows, anything not OpenCode reads as Claude, matching the server's own default. The badge no longer replaces the message count, it sits before it: the count is real on Claude rows and a hardcoded 0 on OpenCode ones (the session list has no count field and a real one costs an HTTP call per row), so those rows show the badge and no count rather than a zero that means "never asked". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
adaaba658c |
list opencode sessions from every project, not just the serve's own
An opencode session in a git directory never appeared in /chat. The list read GET /session,
which answers for ONE project — the one the request's directory resolves to, and with no
x-opencode-directory header that is the serve's own cwd, DATA_PATH/opencode_server. Not a git
checkout, so it resolves to the catch-all project `global`, along with every other non-git
directory. That is why the default chat dir listed fine and nothing looked broken: a cwd that
IS a checkout gets its own project, and chat pwds are checkouts.
Measured on the live serve before changing anything: /session returned 8 sessions, /api/session
13, the five missing ones being an old project's. A session created in a git directory came back
0 times from /session and 1 from /api/session.
/api/session spans projects, so that is now the list. The per-id reads stay on /session — they
answer for any session regardless of project, verified 200 with and without the header.
The trap, and the reason listSessions normalises rather than returning the response: the two
surfaces disagree in silence. /session carries the working directory as top-level `directory`,
/api/session as `location.directory` with no top-level field, inside a {data: …} envelope.
Swapping the endpoint without the mapping leaves `directory` undefined on every session, which
the cwd filter turns into an empty list — the same shape as the metadata.officer.cwd bug this
filter already had once.
Verified against the live serve: with the mapping, a session in a git directory and one in the
general chat dir both resolve to their cwd, and every session carries a directory.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
9d2da49572 |
re-land the client half: hold the socket across a remount, and queue what was typed
Three commits backed out a few hours ago as collateral, restored together because they are one fix: |
||
|
|
d9857eef7c |
re-land: deliver a chat turn to every socket watching it, not the newest one
This is |
||
|
|
b89a562614 |
back out tonight's socket changes
Reverts |
||
|
|
66a41d0813 |
reclaim a connecting socket instead of orphaning it
Follow-up to
|
||
|
|
bc13450fad |
stop closing the chat socket on every remount, again
Re-applies |
||
|
|
31ffe084f5 |
revert the second server too: one officer, one token, this origin
Andre wants to log out and log back in against a single server, so this takes out |
||
|
|
52d567874f |
authenticate the chat socket the same way every request is authenticated
Reported after the revert: the app loads, the old layout is back, history lists — and the socket never reaches connected. The two doors disagreed. `createClient` accepts a token from seven places: window.officerBearerToken, two body datasets, an `?officerToken=` query param, PERTENTO_EDITOR_AUTH_TOKEN, localStorage and sessionStorage. The chat socket url read exactly one of them, `localStorage.BEARER_TOKEN`, so a token held anywhere else authenticated every HTTP request and left the WebSocket with a bare `?token=`. That failure is silent and reads as a dead server: verified here, an empty token closes with 1002 "Expected 101 status code", and the hook's retry loop repeats it forever. Nothing logs a missing credential, so the app looks fine in every way except the one that matters. Resolution is now one exported function, `resolveBearerToken`, used by both. The point is that it cannot be re-spelled: this bug is the second spelling drifting from the first. Predates the tabs work and survived reverting it, which is the evidence it was never a panes bug. Not fixed here, same shape, left alone deliberately: Terminal, Desktop, AudioStreamPlayer, the pipeline and task runners, JobDetail and EmailList all build socket or fetch urls from `localStorage.BEARER_TOKEN` directly and will fail identically for the same user. Typecheck clean. 600 pass, 2 fail — cliamp and pty, unchanged and unrelated. Not verified in a browser; Andre has the only client that reproduces it. 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: |
||
|
|
7726c9fc71 |
let every client watching a chat receive it, not the newest one
Reported from two devices at once: typing on the iPad, reading the reply on the Mac. Sending from the Mac produced nothing there. Both halves are one field. A session held `ws`, a single socket, and `attachWs` assigned it. So the newest attach silently took the turn away from whoever was already watching — and with a tab now holding up to three panes, plus a phone and a laptop on the same conversation, several sockets per session stopped being exotic and became the ordinary case. Now a Set, and every message goes to all of them. `detachWs(sessionId)` was worse, because it named no socket: it nulled the field on ANY close. A stale client going away therefore killed delivery for the client that had attached after it, which is the "nothing happens on the Mac" half. It takes the socket now and removes only that one, and the idle GC is armed only once nothing is left watching — otherwise a close would collect a session another pane is still reading. endTurnIfAgentIsGone takes the whole set for the same reason: a cut-off notice explains a spinner that will otherwise never stop, and telling one of three clients leaves two spinning. Typecheck clean. 600 pass, 2 fail — cliamp path-escape and the pty transport test, both failing identically on master before this change. Nobody has clicked it; the two devices that reported it are the test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cb7ab55cca |
stop asking what was playing, and say what the socket is doing
Two things. The music now-playing restore is disabled on the web. The music sidecar is not running on every machine that serves this app, so every page load fired /music/now-playing and logged a 503 in the console of a browser that was not there for music. Restoring a paused track is a nicety; a permanent error on every load of every screen is not. The player is untouched — it simply no longer asks what WAS playing. And the chat socket now logs its own lifecycle: create, open, close with code and whether it was stale or tearing down, every message received, and every message sent or queued with the socket readyState. window.__officerWs = false turns it off. This is instrumentation I should have added two rounds ago. A pane connects and then sits silent, and I have now reasoned from this hook source three times without explaining it — the browser says a socket closed and never says who closed it or whether the message left. The handover doc says instrument before theorising and I did not follow my own note. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bcb3d6d621 |
stop closing the chat socket on every remount
A pane on a remote server never connected: the console showed the socket closing before the handshake finished, over and over, and the pane sat on Disconnected. The stack named it — commitPassiveUnmountEffectsInsideOfDeletedTree plus doubleInvokeEffectsOnFiber. The pane subtree is deleted and remounted, and the cleanup closed the socket each time, while it was still CONNECTING. The replacement was then closed in turn. React dev StrictMode double-invokes every effect on mount, so a fresh pane could churn forever and never hold a connection. The cleanup cannot tell a remount from a real unmount at the moment it runs, so it no longer tries: the close is deferred a tick and cancelled if the effect re-runs. A remount reclaims the live socket and the handshake completes; a real unmount has nobody to cancel it and closes a frame later, which costs nothing. Ruled out beforehand, by direct test: alpha accepts that exact key over wss on the first try, with and without a browser Origin. The server was never involved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
243bd04d97 |
queue what you typed before the socket was ready
Reported from the mac: the alpha pane opened and read fine, and sending produced nothing at all. The console showed the socket closing before it was established. send dropped the message — readyState !== OPEN returned, silently, no error and no retry — so enter did nothing and no turn ever started. Alpha was never at fault: the same key opens that socket from outside the browser on the first try. The window is not rare. React dev StrictMode double-invokes effects, so every socket is created, closed and recreated on mount, and a reconnect reopens it again; with three chat panes there are three sockets doing it at once, and one is always briefly not OPEN. One pane with one stable socket is why this never bit before. Queued and flushed on open, in order, after the resume/attach handshake rather than in front of it. Bounded at 50 so a socket that never returns cannot grow it without limit, oldest dropped first because the newest message is the one being waited on. The mobile chat app has had this queue all along, for this exact reason. I read it this morning, wrote the reason down, and did not port it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4b3668f03c |
make a pane actually open the conversation you clicked
Reported: three panes, MacBook selected, click a chat and the body says "No sessions yet".
Two causes, both from panes bypassing the screen-level machinery on purpose.
The transcript was never loaded. The screen resolver fetches it and writes to the shared
channel, which a pane deliberately does not read, so the pane got {id, title, cwd} and
nothing else. It resolves its own now, from ITS server — two machines can hold the same uuid,
so asking the wrong one is not merely empty, it is wrong — and shows a spinner while it does
rather than an empty conversation.
And the row navigated. That put /chat/<id> in the address bar, which reset the list cwd to
the default — empty on that machine — which is the "No sessions yet" he actually saw. In a
pane the directory is the pane, not the route: three panes cannot share one URL. Outside a
pane everything still comes from the route exactly as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
80d66538c2 |
fix the render loop I was warned about in the file I edited
React #185, maximum update depth, and the page with it. usePublishChatTabName named useGlobal setter as an effect dependency. useGlobal rebuilds that setter every render, so the effect re-ran every render, set global state, and rendered again. The publisher directly above it in the same file documents this exact hazard — I copied the shape and not the reason. Now through a ref, depending on the string alone, identical to usePublishPageTitle. Also stabilised setPaneTarget with useCallback. It is handed to every pane as onChange and a pane puts it in a context others read, so a fresh identity each render is the same loop waiting for the first consumer that depends on it. The active tab key is read through a ref so it never has to be a dependency. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2d1fc05518 |
browse the directories of the server the pane is on
Reported from the iPad: MacBook selected, and the directory picker still listed alpha folders. Three layers all defaulted to this origin — useFilesAPI, DirPickerModal and PwdSelector — so the pane pointed one way and the pickers another. Same defect as browseDirectories in the mobile app, found this morning: a path only means something on the machine it came from, and offering another machine folders is worse than offering none, because picking one silently runs the agent somewhere that does not exist. The dir-picker cache is keyed by server too. Without it one machine tree is served from cache under the other name, which looks like the fix not working. Other useFilesAPI callers pass no server and are unchanged — the code editor and the message bubble still read this origin exactly as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8bab607366 |
name a chat tab, and let that name win the page title
Click the active tab (or double-click any) to rename it inline — Enter commits, Escape cancels, blur commits, and an empty value hands the tab back to its derived name. Same shape as renaming a conversation, which is the gesture that already exists here. The name outranks everything: chatTabName ?? label ?? override ?? route. It is the most specific statement anyone has made about the page — more specific than the conversation inside it, since there may be three, and more deliberate than a browser-tab name typed earlier on a different screen. Only a name you TYPED is published. Publishing the derived label would restate the title the chat already publishes one tier down, and would then outrank a browser-tab name for no reason the user could see. Cleared on unmount, or every other screen would keep being called by the chat tab you last had open. The rename field seeds from the typed name only, never the derived one — pre-filling a name the user never chose makes Enter silently adopt it as if they had. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a19895c216 |
tabs and panes: several conversations, several machines, one window
The iPad layout in the browser. A tab holds one to three panes; each pane is a whole chat — its own server chips, its own list, its own conversation, its own socket. The blocker was that chat:selected-session is ONE channel for the screen, so two detail panels would have shown the same conversation. A pane now provides its own selection through context and usePaneSelection prefers it; outside a pane the context is absent and the channel behaves exactly as before, so the dashboard chat panel and the mobile layout are untouched. Context rather than props because SessionList and ChatDetailPanel sit at different depths and neither should know whether it is inside a pane. A pane shows its LIST until something is open and the CHAT afterwards, with one way back. Mobile can afford both at once inside a pane; three of those in a browser column would leave nothing for the conversation itself. The layout lives in one unscoped localStorage entry, deliberately not per server — a tab holding one conversation from the laptop and one from alpha belongs to neither. Pane keys are re-minted on restore, because keys from a previous page whose counter restarted at zero make React reuse the wrong subtree and a conversation appears in the wrong column. What this gives up, and it is the only thing: /chat/<id> still deep-links but can only open in the first pane. With three conversations on screen there is no single one for the address bar to name. WorkspaceView and the fixed three-panel layout are gone from this screen; the panels themselves are unchanged and still registered for the dashboard. Typecheck, 602 tests and the SPA bundle all pass. Nobody has clicked 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
|
||
|
|
b7184283e0 |
app store: a screen, so this can be clicked instead of curled
/app-store, built to the platform's own conventions: a locked WorkspaceView with two panels, the selection in `?selected=` rather than a channel, and rows that are real links so cmd-click and a pasted URL both work. `?selected=` and not a /app-store/:id detail route, per docs/navigation-audit.md: this is a master list with a live preview, and linking rows to a detail route would make the detail the whole page and destroy the side-by-side. Both panels read the URL independently — the list and the detail cannot disagree if neither is telling the other anything. The install form is generated from the catalogue's fields rather than written per service, which is what lets a sidecar shipping from its own repository present a form nobody here wrote. `existing` is first in `modes` by catalogue rule, so the default selection is "I already have one" — the answer that avoids starting a second copy of something already running. States are distinguished rather than flattened. Blocked is amber and titled "Needs you", not an error: everything worked and it is waiting for a token only a person can mint. Installed-and-enabled but with a dead process shows a warning rather than a tick that lies. And the disable/uninstall copy says plainly that data, configuration and tables are kept either way, because that is the question anyone hesitates over before clicking. The dock tile is CORE, not plugin-derived: the store is how every other feature arrives, so it must never be one of the things that disappears. Verified through the API the screen uses — 14 items, email reporting installed/enabled with its process online, and /app-store present in the capability routes so the tile renders. NOT verified in a browser: no page has been opened, so the rendering itself is reasoned rather than seen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dd2be8c8b3 |
app store: never assume a service is local, or on its usual port
An instance may be on another host, behind a reverse proxy on 443 under a path prefix, on a tailnet address, or on an arbitrary port because the usual one was taken. All ordinary self-hosted setups, and each one a case where assuming otherwise produces a connection that fails later with no clue why. Two places were sloppy about it. Jellyfin's placeholder read `http://localhost:8096` and Transmission's `http://localhost:9091`, which quietly teach that a service must be local and on its project's default port; both now show remote examples, and the field type says why. And nothing validated what was typed, so a bare hostname or a URL with a token in the query string was stored as-is. The rule: reject only what cannot work, normalise what is merely untidy, have no opinion about the rest. No check that the host is local, that the port matches a default, or that the scheme is https — a tailnet HTTP service is completely normal. Trailing slashes go, because `${url}/api/x` otherwise doubles the separator: accepted by some servers and 404 by others, which is the kind of difference that reproduces on one machine and not another. Query strings go, because that is where a token hides, and it would sit in a column meant for a location. Credentials in the URL are refused for the same reason — outside the encrypted secret, and in every log line that ever prints it. A missing scheme is named rather than called invalid: it is the commonest mistake, because it is what people type into a browser. Verified through the API: `memos.example.com` is blocked with the fix quoted back, and `https://memos.example.com:8443/memos/` installs and stores normalised — remote host, non-standard port, path prefix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2ef4efd39b |
app store: ask before repointing a service that is already connected
Found by breaking it. Installing a sidecar that was already configured replaced its connection row with the new install's, silently repointing a working service somewhere else — during testing that took the live Transmission from :9091 to a scratch container on :18092, and the only symptom was that it stopped working. Install now blocks instead of overwriting, naming both URLs and offering the choice. Blocked rather than failed because there is a sensible answer and the user is the only one who has it: keep what is there, or reinstall with `replaceConnection` to change it deliberately. Harmless on a fresh machine; this is entirely for the one with an existing setup. Verified against the live row: an install pointed at a different URL is refused and the original connection is still there afterwards. Also makes "do you already have one?" structural rather than a UI convention. Three tests: anything that can provision must also offer `existing`, `existing` must come first in `modes` since that is the order the prompt uses, and it must ask for a URL. A new entry added later cannot quietly offer only "provision one for me" — which is how someone with a working Immich ends up with a second one and finds out when two libraries disagree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dc6b623ee1 |
talk to two officers at once from one browser
The chat app on the iPad does this already and this is its model, not the music app one. Music keeps its active server — one library at a time is the right question there. Chat is the exception: two panels side by side, one on the laptop and one on alpha, both live, no switching. The mechanism is one string. A panel holds a serverId; that same string picks the base URL, the credential, the websocket host and the tail of the react-query key. Nothing global is consulted when it is named, which is exactly why two can be live at once — there is no active server in connections.ts at all, because there is nothing to switch. THIS ORIGIN IS NOT IN THE LIST. It is represented by null, so every existing useClient() call is untouched and adding a connection cannot break the app you are already signed into. That property is what makes this shippable before anyone has tried it. A second server is reached with an ofk_ API key minted there, verified against /api/auth/me before it is stored — a URL typo and a key from the wrong machine are otherwise indistinguishable from an empty conversation list an hour later. Copied deliberately from the mobile code: the base URL is derived per call rather than memoised (a cached one hands back whichever server was asked for first), the row stamps its server onto the selection BEFORE navigating (or the resolver reads the transcript from this origin, where two officers can hold the same uuid), and changing server clears the cwd and the open conversation, because a path from the machine you left names nothing on the one you arrived at. Not yet opened in a browser. Typecheck and 602 tests pass, and the cross-origin request with an API key is verified by curl, but no human has clicked any of this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e83585dd42 |
app store: containers follow the sidecar through enable, disable and uninstall
Tier two works end to end. Transmission installed through the API with a real container, verified on
this machine and then removed:
install preflight, provision, connect, schema, assets, process — container up, health-checked,
connection written from what the setup script printed
disable process stopped, then container Exited(0), data intact
enable container back up, then the process
uninstall container gone, install row gone, DATA UNTOUCHED — config, compose file, downloads and
watch directories all still present
Order matters in both directions and it is opposite each way. Enable brings the container up first: a
sidecar that starts before its upstream exists spends its first seconds failing health checks and
logging about a service that is merely not up yet. Disable stops the process first, for the same reason
in reverse.
`down`, never `down -v`, and no `--rmi`: the volumes are the user's data and the images are shared and
expensive to re-pull. Both are deliberate omissions, stated so nobody adds them later as a tidy-up.
Uninstall only brings down containers for `mode: 'provisioned'`. An `existing` install points at a
service the user runs themselves, and `down` there would stop a container Officer never started.
Every compose call tolerates a missing directory rather than failing. Three call sites can legitimately
arrive with nothing there — an `existing` install, a failed install that died before writing the file,
and a resumed uninstall re-running a completed step — and erroring would make a row impossible to
uninstall, which is the one state a user cannot escape.
Adds an `assets` step, before `process`: the dock reads manifests as soon as the install is recorded, so
an icon arriving a moment later shows as broken on the first render. And composeDir is recorded from the
install rather than derived later, because the directory is the user's and they may move it — uninstall
must not guess at a path it is about to run `down` in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
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> |
||
|
|
a3dbda7d3b |
phase D: delete the subprocess path
The serve is now the only way an opencode turn runs. runner.ts and its tests are gone, and so is the OPENCODE_TURNS switch — there is no fallback engine any more, and the recovery for a bad day is git rather than a config flag. Deliberate, and cheap right now precisely because nothing depends on opencode yet. What goes with it: mapRunLine and its NDJSON fixtures, the temp-file spill for --file image attachments, the supersede-and-kill dance, the process watchdogs, the pidfile-adjacent child tracking, and stopAllOpenCodeTurns. All of it existed to work around stdin being /dev/null. Verified after deletion, with no env var set at all: tool call, tool result, 5 streaming deltas, text and cost, through the real chat socket. Also corrected the comments the deletion falsified rather than leaving them to mislead — the module header, the wire contract description of opencode:run-streaming, and serve-runner own header, which still announced itself as off by default. One difference worth stating: shutdown no longer kills anything. Turns run inside the serve, which is a separate process that survives us, so officer stops routing them and says so in the transcript. When a subprocess ran the turn, failing to kill it orphaned it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
82e9fdacc2 |
dock: the shell keeps its own items, sidecars contribute theirs
ALL_DOCK_ITEMS was a hardcoded list of everything, so a fresh machine offered Photos, Jellyfin, Transmission and the rest — each leading to a screen reporting itself unavailable — and adding a sidecar meant editing the shell. Neither survives sidecars shipping from their own repositories. Split in two. CORE_DOCK_ITEMS is the baseline that exists on every install: chat, files, terminal, the app's own screens, and Gitea, which is in the light profile because it fronts a remote instance. Everything else is derived from installed sidecars' UI manifests, delivered with /capabilities. Sent with the capability answer rather than fetched separately so the dock has ONE source. Two requests means two moments, and a dock rendered between them shows a tile for something uninstalled or nothing for something installed. Filtered by capability server-side too: a member is not handed the manifest of a feature they cannot use, because "hidden in the client" is the kind of privacy that lasts until someone opens the network tab. Verified live. The owner — who bypasses every permission check — does not bypass this: /photos is absent from routes and present in deniedRoutes because Photos is not installed. Flipping a row's `enabled` makes its tile leave and return with no process touched. Two things fell out. A manifest can declare extraTiles, because CalDAV is one sidecar presenting as Calendar AND Contacts, and collapsing them to keep the model tidy would make the app worse. And DEFAULT_DOCK_PATHS no longer pins /music: useDock drops a path with nothing behind it, so the default dock came up a tile short on any machine where Music was never installed — a default that references an optional feature is how an app looks subtly wrong on a fresh install for no stated reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b79eca45fb |
send images on the serve path too, before anyone switches to it
runOpenCodeTurnOnServe ignored params.images entirely, which is defect B4 rebuilt on the new path: the image renders in your own bubble and the model never receives it, with nothing reporting a loss. Fixed before the path is switched on for anyone rather than after. data: URIs, not file://, and that is measured — the wrong choice is accepted with a 200 and then dies inside the turn with "Anthropic Messages media must contain valid base64". The data URI round-trips and the model describes the image. Strictly better than the subprocess path here: no temp file to spill and nothing to clean up, because the bytes travel in the request. Both prompt paths carry them — an ordinary send and a mid-turn injection. Verified end to end through the chat socket with the serve engine on: a red png came back "**Red**", with deltas streaming. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
209e916343 |
app store: publish a sidecar's assets to public/plugins/<id>/
Real PNG icons are coming, so this is the path they arrive by: a sidecar ships its assets beside its own code, and install copies them to public/plugins/<id>/ where one static route serves them. Copied rather than served in place because a sidecar shipping from its own repository has its assets wherever that repository was unpacked, which is not a path the web server can be taught at build time. One predictable destination means the serving rule never has to know how many plugins exist or where any came from. It also makes assets a property of the INSTALL: uninstall removes them, and a plugin nobody installed serves nothing. Needed a new route, and the reason is a trap worth recording. `publicRoutes` in server.tsx is built by globbing ./public at BOOT, so anything copied there afterwards is invisible to it — the first install of a plugin would show a broken image until the server was restarted, and "install it, then restart to see the icon" is not an install. `/plugins/*` resolves per request, like /novnc/* and /vendor/* already do. Unlike those two it answers 404 rather than 500 for a missing file: an unpublished icon is an ordinary state on a fresh machine, and a 500 would put a red line in the log for every dock render. Proven end to end with a real asset: slskd's icon moved from public/slskd.png into the sidecar's own assets/, published against an ALREADY RUNNING server, and fetched at 200 with the right bytes and content-type — 404 before publishing, no restart between. public/plugins/ is gitignored: it holds copies, and the originals live with each sidecar. 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> |
||
|
|
9084fabbf6 |
app store: note where a real PNG icon's bytes will have to live
Real icons are coming and the manifest field already exists — slskd uses it. What is not decided is where the bytes come from for a sidecar that ships from its own repository: /slskd.png works only because it sits in the platform's public/, which a marketplace plugin cannot write to. Records the three options and their trade — marketplace URL (loses icons offline), served by us from the sidecar's directory (works offline, needs a route and caching), or a data URI (no fetch, but bloats every manifest) — so the next person meets the question instead of assuming the current path generalises. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
27c1331b3c |
app store: a sidecar carries its own dock tile and routes, and an uninstalled one has neither
Two gaps, both from the same root: the app knew what an account MAY use and not what this server actually HAS. Availability is now subtracted server-side in the /capabilities answer. "Installed" is orthogonal to "permitted" and the owner is subject to it — the owner bypasses every permission check, but a capability they hold unconditionally still means nothing if its sidecar was never installed. Without this the dock on a fresh machine lists Photos, Jellyfin, Transmission and the rest, each leading to a screen that reports itself unavailable. Computed on the server rather than intersected in the client, so the rule lives in one place: the dock already reads `/capabilities`, and making it read a second list and combine them is how a member's dock and an owner's dock drift apart. `unavailable` is returned alongside `deniedRoutes` because the two mean different things to a UI — "not yours" versus "not here yet, install it". A disabled sidecar counts as unavailable: disable stops the process and its container, so the feature genuinely does not work, and leaving its icon would make disable look broken rather than effective. Reading install state failing subtracts NOTHING, matching useCapabilities' deliberate fail-open. Each entry now also carries a UI manifest — name, icon, colour, rootRoute, routes — because a sidecar shipping from its own repository has to be able to say what it looks like. The icon is a NAME rather than an imported component: a manifest has to survive being JSON from marketplace.officer.dev, which a lucide import cannot make. Tests pin the manifests against the capability registry, so a tile cannot appear for a route the server guards differently, and against each other, so two sidecars cannot claim one root route. No backfill, by decision: this is proven on a blank machine first and applied to alpha from scratch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a06422bd4c |
phase B: run a turn through the serve, behind a switch
OPENCODE_TURNS=serve picks the new engine; unset keeps the subprocess, which is the default and stays the default until this has been lived with. A bad evening should cost one restart, not a revert. Claude is a different sidecar and is untouched. Verified end to end through the real chat socket: session:init -> tool:start(bash) -> tool:result -> assistant:delta x3 -> assistant:text -> result, cost in=304 out=73 Those deltas are the first token streaming an opencode turn has ever produced in officer. Stop is now an INTERRUPT: the turn ends and the session survives — verified by sending a second prompt to the same session afterwards and getting an answer, which killing a subprocess could never do. Reads the LIVE global stream rather than the durable per-session one, because it is a strict superset — same tool.called, tool.success, step.ended, text.ended, plus the deltas that are the whole point. Global means one socket carries every session, so everything filters on sessionID; one subscription is shared for the process rather than one per turn. A turn ends on step.ended with finish != tool-calls. tool-calls is a step boundary MID-turn, and treating it as terminal would cut every tool-using conversation in half. delivery is stated explicitly as queue because it DEFAULTS to steer, which injects into a running turn — wrong for an ordinary send, where two quick messages would merge into one. Wiring steer to the button that means it is phase C. What phase B does not do: read the durable stream. The sidecar still commits every event to chat_session_events as it arrives, so durability is unchanged, but recovering a turn this process never saw needs the ?after= cursor and is its own change. 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> |
||
|
|
35970146bb |
connect the opencode credential at sidecar boot
opencode keeps credentials in two unrelated places. The CLI, opencode run and the legacy /session surface read auth.json. The newer /api surface — the one with steer, queue, interrupt and a resumable per-session stream — reads its own integration store and knows nothing about that file. With none connected it does not fail. It falls back to what needs no credential, the free tier, and a request for a paid model is never executed: prompt accepted, admitted, prompted, then no step, no error, no message, forever. That silence cost most of an afternoon and would cost it again on every new machine — alpha included. So the sidecar does it, rather than depending on someone having run a curl. Best-effort and never blocking: turns go through opencode run, which reads auth.json and does not care. Retried, because /api/health answers before the integration store is ready — the first version of this shipped without a retry and failed on its very first real boot with a 500, while the identical request succeeded seconds later. Only 5xx retries; a 4xx means the request is wrong and repeating it just prints the same complaint six times. Verified by deleting the credential, restarting, and running sonnet on the new pipeline with no manual step. 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> |
||
|
|
6e9a8ee42f |
let the extension use the bare officer url, no path
Follow-up to the /vaultwarden mount: the suffix is superfluous if officer can tell a bitwarden client apart, and it can. Most of vaultwarden surface does not collide at all — /identity, /notifications, /icons and /events belong to it and to nothing here, so those are served at the root by path alone, no sniffing. Only /api collides (vaultwarden has /api/settings/domains, officer has /api/settings), and there the client says who it is: every bitwarden client stamps Bitwarden-Client-Name, older ones Device-Type. Trusting a client header is fine because this is ROUTING, not authentication — the worst a forged one achieves is reaching vaultwarden, which then demands its own credential exactly as it would have. Nothing is authorised by it. Registered before /api so it wins for a bitwarden client, and narrow enough that an ordinary officer request never matches. Verified: /identity reaches the proxy, /api/sync with the header diverts, /api/chat/models without it still answers 401 from officer, and the SPA is untouched. /vaultwarden still works for anything that prefers an explicit path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f2e38ed9a7 |
serve vaultwarden at officer own host, without officer auth
So the bitwarden browser extension can point here and the separate public vaultwarden hostname can be taken down. /api/vault cannot serve it: that router requires an officer session and REPLACES the caller Authorization header with a server-held vaultwarden token. Right for our own clients — the device then holds no vault credential — and impossible for a third-party client that gets its own token from /identity/connect/token and has nowhere to put a platform JWT. So a separate mount rather than a mode of that router: blending them would put an unauthenticated branch inside the authenticated path. This one forwards Authorization untouched and rewrites nothing. Leaving it open is not a new exposure — everything here was already reachable at the vaultwarden URL it replaces, behind the same master password, and officer cannot add a check it has no credential for. It is also going behind tailscale. Temporary. The end state is our own extension reusing @officer/vault, which already runs as a plain JS bundle outside react native (the iOS autofill extension hosts it in JavaScriptCore), against the /api/vault/session/login broker — then nothing addresses vaultwarden directly and this mount is deleted rather than adjusted. Needed its own entry in server.tsx: only listed paths reach hono and the rest fall through to the SPA, so without it the endpoint answered 200 with the react shell — a missing route that looks like a working one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b07cae142f |
adopt on a resume even when the harness is a guess
Regression from my own B7 change, reported within the hour: turns collapsing to "turn completed without output" and coming back only on refresh. B7 stopped resume-cursor defaulting an unidentified session to claude-code. Correct for the durable cut-off row, wrong for adoption: useChat sends model only if modelRef.current is set, so a reconnect without one is routine, not exotic. Declining to adopt left the socket unbound to the live session, so the running turn output went nowhere — and a refresh looked like a fix because it rebuilds from the durable log. Adoption is about DELIVERY and must be generous; only the durable write needs certainty. So adopt on the default again, mark it as an assumption, and skip the cut-off check on it. That keeps B7 fixed — no false "agent went away" written against an opencode session — with no unbound sockets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ecc10ae6af |
name a live opencode row from the prompt until opencode names it
Same shape as the claude side, which has never shown a live row without a name. OpenCode titles a session from the conversation and does it well, but asynchronously — so for the whole time a turn is RUNNING, which is exactly what /chat/live shows, the session is still called "New session - <ISO>". Its own title wins the moment it exists; until then the row falls back to the prompt that started the session. Kept per sessionKey, first turn only, so it stays the name of the conversation rather than following whatever was asked most recently. Dropped with the session id it sits beside. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
90e0ca8ab4 |
stop showing opencode placeholder titles as if they were names
OpenCode titles a session from the conversation, but asynchronously — a finished turn of ours ended up called "Single color in oc-red2.png", better than anything we would generate. Until then the session is literally named "New session - 2026-08-10T15:44:17.178Z". That window is exactly when a session is most visible: /chat/live shows turns that are RUNNING, so the placeholder is what the panel catches, and a live row was being labelled with a timestamp string. Recognise it and treat it as untitled, so the good name arrives on its own. Passing --title on the run was the other option and is worse: it fixes the transient case by permanently replacing opencode own title with a truncated prompt, degrading it where it lasts longest. A pattern match rather than startsWith, because a genuine title is allowed to begin with those words. Two defects the tests caught while writing them: a whitespace-only title was not treated as unnamed, and the mapping let undefined through where a string was required. 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> |
||
|
|
73b8111216 |
send images to opencode, which never needed the fork
B4 properly. The composer gate was the honest stopgap; this is the fix. opencode run takes attachments with --file, so images work on the subprocess path we already use — the parity doc had them down as phase 4, behind the serve migration, and they were not. The bug was one omission: handleOpenCodeChat`s msg type had no images field, so the browser sent them, the bubble rendered them, and they stopped at that signature. Nothing reported a loss anywhere. Attachments are paths, not inline data, so the sidecar spills each image to a temp file for the length of the turn and removes it in settle — the same place every other per-turn resource is released, so a killed or superseded turn cleans up too. The load-bearing detail is `--` before the prompt: --file is an array option, so without the separator the prompt is eaten as another filename and the turn dies with "File not found:" followed by the entire message. Confirmed against the binary, and pinned by a test that records argv from a stub. list-models now reports each model own capability instead of a hardcoded false — opencode publishes capabilities.input.image per model and nothing had ever read it. Defaults to false, so a model that does not declare it keeps the affordance hidden. Verified end to end: a red png sent over the chat socket to opencode/claude-sonnet-4-6 came back "Red". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ba71dc1957 |
app store: make it work — pm2, install state, and the routes
Email installs end to end now, which was the point of picking it as tier one: no container, no external
wiring, so the machinery is exercised without the provisioning half.
Verified against the running system, not asserted:
POST /api/app-store/email/install -> {"status":"installed","completed":["preflight","schema","process"]}
row -> email mode=config status=installed enabled=true
pm2 -> officer-email online
second install -> all three steps skipped, process not restarted
disable -> stopped
The server boots with the new router, which is the real test of the capability entry: totality.ts throws
before serve() if a mounted router has none, so booting IS the check passing.
pm2.ts shells out rather than importing pm2 as a library. PM2 is already the supervisor and the
ecosystem file is already the definition of how each process runs; a second thing in charge of that
means two supervisors disagreeing. It also means an owner can undo anything the app store did with a
command they already know. The one fact that matters: `pm2 start <name>` fails for a process PM2 has
never seen, so a first install starts from the ecosystem file with --only, and everything after goes by
name. Callers cannot know which case they are in, so startProcess decides.
Disable stops rather than deletes: a stopped process still shows in `pm2 list`, which is the honest
picture. Deleting would make a disabled sidecar indistinguishable from one never installed.
beginInstall returns the existing row instead of replacing it — that is what makes a retry a resume
rather than a re-provision — and clears lastError on the way in, so a UI never shows a stale failure
beside a working service.
The container half of enable/disable/uninstall is deliberately absent rather than stubbed silently: a
disable that leaves Immich running is a different thing from one that stops it, and the difference is
memory on the user's machine.
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> |
||
|
|
4b9b98efda |
app store: run a sidecar's setup.sh, streaming it and collecting what it returns
Implements the half of the template contract that faces the platform: answers go in as environment and never as prompts, and results come back as OFFICER_RESULT_<KEY>= lines on stdout. A line protocol rather than JSON because the same stream is the user's live log — it goes to a terminal panel while the install runs. A script that must emit clean JSON cannot also narrate, and one that emits both needs a framing convention anyway. This mirrors the @@officer:progress@@ sentinel the job runner already uses, with the same rule: marker lines are plucked out, everything else passes through. parseResults is pure and tested against the realistic near-misses: a line that MENTIONS the prefix without starting with it, an empty value (Transmission with no RPC auth returns exactly that, and blank is a real answer), a value containing `=` (splitting on every one would truncate a credential), and a prefix with no assignment (a script bug — skipped rather than stored as a blank key). Verified end to end against a real script: environment reaches it, stderr is forwarded (docker compose writes its progress there, so dropping it would hide most of what a user watches), OFFICER_NONINTERACTIVE is set so a script that would block fails loudly instead of hanging behind a web form, and a non-zero exit is reported with the tail. Notes an artifact rather than hiding it: the two streams are pumped concurrently, so the error tail can interleave differently from real time. The live log is correctly ordered; only the summary can read out of order. Serialising the pumps would make a script that writes heavily to one stream block on the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4a03b9f84e |
app store: the install step machine, resumable and testable without docker
Install spans a container start, a health wait, an upstream API call and a process start. Any can fail, and one of them — a token only a human can mint — is EXPECTED to stop the run. A straight-line function has two bad options there: unwind everything, or leave a half-installed service that neither works nor uninstalls, which is the state users cannot get out of. So each step is named, completion is persisted, and running install again resumes. planSteps is a pure function of (entry, mode) and the effects are injected, which makes ordering, resume, blocking and failure testable with no Docker, Postgres, PM2 or Immich in sight. 15 tests cover exactly the behaviour that only appears when something goes wrong. Two rules are enforced by the plan rather than remembered at call sites: 'existing' never provisions, so pointing at an instance the user already runs cannot start a container; and the members step is omitted entirely for a service with no user concept, so a Transmission install does not report a step that did nothing — which reads as a silent failure to anyone debugging a member's access. `blocked` is a first-class outcome, not an error. For Immich the container is up and healthy and only its own UI can mint a key; calling that a failure would make a normal install look broken and invite the user to tear down a working container. The blocking step is deliberately NOT recorded as complete, so a resume re-runs the step the human just answered. Results feed forward — provision discovers the URL that connect writes down two steps later — over a copy of the caller's values, so a failure halfway cannot rewrite what an earlier attempt achieved. 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> |
||
|
|
977d14922f |
email: migrate the sync position per key, not all-or-nothing
The previous commit migrated only when SQLite was completely empty, on the assumption that a non-empty file is an authoritative one. A real account disproved that within minutes of it landing. The older Gmail backfill had already written SOME keys into SQLite — last_sync_at and the uidvalidity set — and never the imap_lastuid ones. So the file was non-empty and half-migrated at the same time, all-or-nothing skipped the migration, and nine imap_lastuid keys stayed only in Postgres. A missing lastuid makes the next sync refetch that folder from UID 1: on the mailbox this was found on, 18,755 messages and 6.9 GB. Now merged per key with the file always winning a conflict. That keeps the property all-or-nothing was protecting — a restored older emails.db still overrides a newer Postgres row for every key it has, so it cannot be advanced past mail it does not contain — and adds the keys the file never had, which are exactly the ones whose absence is expensive. Verified against the live account: all 22 Postgres keys present afterwards, imap_lastuid:INBOX restored to 208407, and last_sync_at left at the file's older value, so it re-checks a fortnight rather than skipping it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
539aeca7ec |
reverse the fork decision: the pipeline works, my probe did not
I concluded a few commits ago that the serve new /api/session pipeline accepts prompts and never executes them, and kept turns on opencode run. Wrong. Every probe behind that passed an explicit model claude-sonnet-4-6, and THAT model silently does not run on the new surface — no error, no event, no assistant message. Drop the field and the same request completes. One broken variable in every experiment, read as a property of the system. Measured on 1.18.16, both machines upgraded today: delivery steer injects into a running turn (verified, output changed to order), delivery queue runs after it (verified, ONE then TWO, zero errors), and model selection works via POST /model — just not with sonnet. So the fork is reopened and worth taking, targeting the new surface rather than the legacy message path, which generates fine but has neither steer nor queue. Blocked only on why sonnet dies there while working under opencode run. Third time this project has hit the same trap: opencode accepts input it does not honour and says nothing — directory in the body, location.directory that never existed, now model. A probe that changes one thing and sees nothing has not learned the feature is missing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0c90216c7a |
email: keep an account's sync position inside its own emails.db
The messages were in emails.db and the position — last_sync_at, and per-folder uidvalidity/lastuid — was a jsonb column on email_accounts in Postgres. Two stores for one fact, with an edge that only shows up when you try to move a mailbox to another machine. The expensive part of an email account is the first sync: hours of IMAP for a large mailbox, which is exactly why "copy emails.db to the new server" is the obvious way to bring one across. With the position in Postgres that silently does not work — the new server's column is empty, !last_sync_at says first sync, and the whole mailbox downloads again on top of the one just restored. The other direction is quieter and worse. Restore an OLDER emails.db while Postgres holds a NEWER position and the sidecar skips every message between the two, permanently, because nothing looks below lastuid again. Re-syncing is slow; skipping mail is data loss nobody notices. Not a new idea — the Gmail path already read SQLite and fell back to Postgres, backfilling so the fallback was taken once. Only the IMAP path had not followed. This extracts that pattern so both use one copy, and unifies the isFirstSync fork in accounts.ts, which is how the two drifted apart to begin with. The file wins over Postgres, always, and only migrates when it holds nothing at all. Topping up a partial position from Postgres would reintroduce precisely the divergence this removes. email_accounts.sync_meta is kept and marked legacy rather than dropped: it is the one-time backfill source for every account created before this, and dropping it would strand any that has not synced since. Nothing writes to it now. 11 tests on the migration, aimed at both expensive failures — migrating when we should not, and failing to migrate an account that predates the change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
41663bc207 |
decide the phase 2 fork: turns stay on opencode run
The serve has a second, newer API surface nobody here had looked at, and it publishes exactly what the parity doc calls impossible under stdin ignore: delivery steer and queue on POST /prompt, an interrupt that does not tear down, and a per-session event stream with an after cursor — the durable-replay machinery officer hand-built for claude, as a primitive. That would have made migrating obvious. It does not execute. A prompt is accepted with an admittedSeq, stored, emits prompt.admitted and prompted, and then never steps. Ruled out separately: the model, the permissions (build is *:allow, no pending requests), the per-request location (the surface is location-scoped via header or a deepObject query, supplied everywhere, no change), and a config gate. The legacy POST /session/id/message?directory= generates fine in 17s, so the serve itself works — only the new pipeline is inert. session.next.* is the tell. And not a version problem, which is the part everything here had backwards: this Mac runs 1.18.11 and alpha runs 1.17.9, measured. The dead pipeline was tested on the NEWER binary. The original "this server runs 1.17.9" meant alpha and was copied to a machine where it was false; corrected in runner.ts and the test. So building against it now would produce code that looks finished and does nothing, which is the failure mode this project keeps rediscovering. One request reopens the question after any upgrade, and the doc names it. Also de-flakes the lifecycle tests: they spawn real processes, and a fixed sleep(750) went red once on a machine busy running these probes. Presence assertions poll now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |