56ddb6bebfc9869a9a38d0cc19d2efd3fe3c6b18
71
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
56ddb6bebf |
close the anthropic proxy secret, and re-close it on every boot
data/sidecar/claude-state.json holds proxySecret, and that string IS the owner's Claude Max subscription: officer-anthropic-proxy accepts it as x-api-key, swaps it for the owner's OAuth bearer and forwards to api.anthropic.com. it listens on 127.0.0.1, which every local account can reach. it was 664 in a 775 directory. nothing had ever set a mode - mkdirSync and Bun.write at ubuntu's default umask of 002. confirmed by reading the live secret as 'nobody', an account with no relationship to the platform, and by confirming the proxy 401s without the key so the secret is the only control. this does NOT mean members shared the subscription. spawnClaudeAsMember runs their claude through setpriv --reset-env with a six-name allowlist, so ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY cannot cross into it and their turns go straight to anthropic on their own OAuth. that design is intact - it could just be walked around by anyone with a shell. fixed twice on purpose. state.ts sets 0600/0700 on every write, which is the authoritative fix, and on every write rather than at creation because writeFileSync leaves an existing file's mode alone. secureRuntimeSecrets() sweeps at boot because officer-anthropic-proxy is a separate pm2 process that can recreate the file hours after officer started, and because every existing install has the old mode until something repairs it. it repairs rather than refusing to boot, unlike assertSecretsClosed beside it. the line is who wrote the file: .env is the installer's and we must not guess at it; this is our own output at the machine's umask, so refusing to start would punish the user for our bug. the per-account copies are swept too. no live secret in them today, but they map session keys to transcript uuids for named accounts. tests verified to bite: removing the chmod while still reporting success fails four of them. a chmod does not undo exposure - the secret still needs rotating on the production host. TODO.md has the sequence, including that officer-claude-code caches it and must be restarted alongside the proxy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1b811dfa28 |
make plan mode reachable in chat
opusplan has been in the picker since
|
||
|
|
4c24df93c1 |
a chat can change model mid-conversation
the selector locked on `hasStarted`, so the tier was fixed for the life of a conversation. nothing justified it — the commit that added the flag says only "default model" — and the hazard it resembles is narrower than what it blocked: crossing HARNESS hands an opencode `ses_…` id to `claude --resume`, which reads as a dead agent rather than a wrong dispatch. changing TIER was always safe. so tier is free, harness is pinned, and the server refuses a crossing rather than relying on the UI to. the claude side does it the way the CLI's /model does: `Query.setModel()`, a control request that needs streaming input mode, which is the only mode this file uses. nothing is torn down and nothing is re-resumed — cwd, permission mode and transcript all continue and only the next response changes. an earlier plan here was to kill the session and re-create it with the stored resume id; that would have worked and was unnecessary. opencode already switched per turn (serve-runner POSTs the model to the live session), so only the UI was in its way. FIXES A SILENT MISMATCH while it is here. model reached the SDK once, in createSession's query() options, and every later turn was pushed onto that same call — so a changed model was dropped while session:init, the cost fold and the durable log all reported the new tier. reachable today by leaving a chat idle past the 30-minute GC: the UI said sonnet, opus answered, nothing logged it. the fold now tracks what the harness actually did rather than what the client asked for, and a refused switch reverts the picker. the switch is a durable `model:switched` event and a centred marker in the transcript, because the useful question is the BOUNDARY — which half of this conversation is opus. per-message model was already stamped server-side and shown nowhere, and claude-sessions.ts reads the FIRST model in a transcript, so a switch was invisible in history. tests: turn-stream proves the marker is durable and lands after the previous model's last words; the selector tests pull in opposite directions, so unlocking everything fails one and restoring the blanket lock fails the other. verified by re-breaking both. not done: opencode's `steer` path (a send mid-turn) returns before the model POST, so a switch there applies on the next full turn. session.model also stays stale for opencode tier changes — harness routing is unaffected. |
||
|
|
9203472be8 |
the recovery could never have fired — fix its condition
Shipped
|
||
|
|
e642205207 |
a chat that cannot be resumed stops being permanently dead
A member's conversation answered "Claude Code process exited with code 1" to every message, four times identically, and could never recover. A brand new chat worked throughout. Diagnosing it took an evening and produced two wrong answers — the credential, then the working directory — both disproved by hand on the server, because the one input most likely to be at fault appeared in no log anywhere. Three changes, and the first is the one that matters most next time. ── Say what is being run ── `[claude:spawn:<key>] resume=… cwd=… as=… model=…`, before the spawn. The resume id is NOT the id in the URL — it comes from a stored map, so the two can disagree, and nothing could previously show that they had. Every hypothesis about it had to be tested by hand. ── Carry the child's reason to the person who hit it ── stderr was logged and then dropped, so the client got a number. The tail is now kept on the session and travels with the exit, both to the log and to the user. When the child says nothing at all — which is what happened here — the sentence still reads, rather than trailing a dangling colon. ── Stop retrying a resume that cannot work ── `sawOutput` records whether the child ever produced a single message. False, on a turn that was resuming, means it never got as far as loading the conversation — and since a fresh chat works with the same process, account, credential and binary, `--resume` is the only input that differs. So the stored id is dropped and the next message starts clean. The history is lost to the agent, which is a real cost, and strictly better than a thread that can only ever return an error. Nothing reconsidered that id before, which is why four identical failures in a row was the floor rather than a phase. Deliberately NOT done when the child spoke first: that is a turn that broke halfway, the transcript is fine, and clearing it would trade one bad turn for an amnesiac conversation. That negative case is the second test. `diagnoseExit` is its own module because everything around it needs the SDK to construct — which is how the old single sentence went unexamined for so long. 8 tests. This does NOT explain why the resume failed; it makes the next occurrence say so instead of costing an evening. The cause is still open. tsgo clean, 945 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
027b10bd6e |
step 4/4: the docs say permissions too, and capability means one thing again
44 files of prose — CLAUDE.md, AGENTS.md, TODO.md, 20 docs, both plugin design
documents, and the comment surface the earlier steps could not reach.
Applied against an explicit keep-list, not swept, because the word turned out to
have SIX meanings in this repository rather than the three the offscale doc
recorded:
permissions renamed (steps 1–2)
$OFFICER_ROOT/capabilities/ KEPT — the item store, and now the only thing
the word means that is ours
sidecar routing keys renamed to `handles` (step 3)
Lightning wallet KEPT — a domain term, and on the wire to the mobile apps
terminfo queries KEPT — XTGETTCAP, in the pty sidecar
InvoiceShelf KEPT — per-resource { write, bulkDelete } flags
The sweep still falsified two things, both caught by checking rather than by
review, and both in prose that discusses more than one meaning at once:
CLAUDE.md began claiming the item store lives at `$OFFICER_ROOT/permissions`.
It does not; that directory is on disk and full of skills and tools.
And the offscale doc's own note about the collision became
"Named `permissions`, NOT `permissions`" — a sentence that had eaten the thing
it existed to warn about.
Both restored, and the note rewritten to say what is now true: capability means
one thing of ours, and three that belong to somebody else's vocabulary.
Verified live after restart: self and admin permission endpoints 200, gated
route 200, agent-status 200, 9 grants intact with 6 permissions offered.
tsgo clean, 797 tests, 787 pass, same 7.
The rename is done. Four steps, no data lost, no client break that survived
the step it was introduced in.
|
||
|
|
5afa2d832e |
step 3/4: sidecar routing keys become handles — NOT YET VERIFIED LIVE
Committed before restarting, deliberately: this changes the registration wire, and the restart that proves it also bounces officer-claude-code, which is what the session doing the work runs on. An uncommitted 25-file protocol change is worse to inherit than one marked unverified. `capabilities: ['music']` → `handles: ['music']` on the registration message, across 20 sidecars, both plugins, the connector, the registry and the protocol type. findSidecarByCapability → findSidecarHandling, waitForCapability → waitForHandler. The name: a sidecar already has `handleCommand`, so the list is literally what it handles. `provider` was rejected — 390 existing uses and it already means websocket door. `serves` was rejected — collides with HTTP serving, which sidecars also do. THIS IS A BREAKING WIRE CHANGE with no compatibility shim. A platform expecting `handles` reads `undefined` from a sidecar sending `capabilities`, registers it with an empty list, and every sendCommand finds nobody — chat, terminal and music all fail with "No sidecar handling X is connected". So the whole estate has to restart together; there is no rolling upgrade. If it goes wrong: `git revert HEAD` and `pm2 restart all` again. The registry is in-memory and nothing about this touches the database, so a revert is complete. Found a fifth meaning of the word on the way, correctly named and untouched: the pty sidecar's terminfo capability queries (XTGETTCAP escape sequences). That is now five — permissions, the item store, routing keys, Lightning wallet features, and terminfo. tsgo clean, 797 tests, 787 pass, same 7. Not exercised against a live sidecar. |
||
|
|
e13128846b |
offscale is a plugin
headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.
api/router.ts the thin auth-gated proxy, now at /api/offscale
sidecar/ 18 files, the whole headscale contract and its admin keys
db/ schema + queries, offscale_servers
web/ 26 files as panels and a layout — no screen, per the rule
removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.
the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.
AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.
install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.
verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.
757 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
fe0012635a |
stop leaking the parent claude session into the one we spawn
pm2 inherits the environment of whoever ran pm2 start, so restarting this sidecar from inside a claude code terminal — which is how it is restarted most of the time — bakes that terminal's session into the daemon. right now this process is carrying CLAUDE_CODE_MESSAGING_SOCKET for an unrelated pid that has been alive for an hour and a half. three of these were already stripped; the rest arrived with 2.x and were never added. this is hygiene, not the fix for today's hang — a spawn was verified to succeed with the whole set present — but a child attaching to a stranger's ipc socket is not a failure anyone would recognise from the symptom. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
547662842b |
a dead claude process no longer hangs the chat forever
the sdk runs two independent tasks per session: the consumer loop (`for await (const msg of q)`) and an input pump that writes the queue to the child's stdin. the consumer loop's `finally` is what removes a session from the map — but when the CHILD dies it is the input pump that fails, with `ProcessTransport is not ready for writing`, and that rejection neither ends the consumer loop nor is caught anywhere. so the loop stayed parked on a stream with no writer, `finally` never ran, the session stayed in the map, and spawnClaudeStreaming handed every later turn to the same corpse. each one pushed a message onto a queue nobody drained: no error, no result, no timeout. the client spun forever and the only trace was one unhandledRejection line in the sidecar log. observed on the host today; the only cure was pm2 restart officer-claude-code. a member's turn already supplied its own spawn function because it has to go through setpriv. the owner had none, and therefore no place to observe the child — which is exactly why its death was invisible. so give the owner one too, and wrap both in watchChild: on exit or error, drop the session from the map and, if a turn was in flight, tell the client. emitting only while generating is deliberate. a child that exits between turns is invisible to the user, and an error bubble arriving in a chat nobody is looking at would be noise — dropping the map entry is the whole repair there, because the next turn builds a fresh session and resumes the transcript by id. the stall timer now tears the session down as well. it used to keep it — "it may still be working, and the next turn resumes it" — which is right for a slow agent and wrong for a wedged one: the session stayed broken, so every later turn hung the same way and "send again to continue" was a lie. sessions with background tasks outstanding are still left alone, since a job can be silent far longer than ten minutes and still land its notification. verified live against the real manager: killed the child mid-turn, saw the error surface and the next turn rebuild the session. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fbb6d6c78c |
the agent sidecar crashed on boot without the email plugin
user-instance.ts read `getEmailAccounts` at module level, unguarded, in a
top-level await. Commenting the email schema out earlier tonight means
`email_accounts` does not exist on a core install, so that query throws, the
rejection escapes, and officer-agent exits — into the PM2 restart loop, taking
chat with it. Chat is core; the agent sidecar is what spawns `claude`.
The same file's header documents this exact failure being fixed once already, for
`resolveOwner`: "A query that THROWS — Postgres restarting, or not up yet —
escaped this function, rejected the top-level await, and exited the process into
exactly the PM2 restart loop the comment below says it exists to avoid." That cost
a session on 2026-08-10. This line has the identical shape and was never covered,
because until tonight the table always existed.
Guarded now: no accounts, a warning, and the email_db tool sits idle. An agent
must start without email — it is one tool, not a prerequisite for chat.
Checked the rest while there: the only other top-level awaits in the core
processes are resolveOwner (already hardened), sign (now backed by the secret
store, which creates on demand) and assertSecretsClosed (throws on purpose).
The three vault calls in core auth — signout, revoke, panic — are
`clearVaultTokens(...).catch(() => {})`, fire-and-forget, so a missing table is
swallowed. They are fine as they stand.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
571d0a62ff |
delete the bug-report discord webhook, and the last dead HOME_DIR reads
DISCORD_BUG_REPORT_WEBHOOK is gone, with sendToDiscord and its helpers. Reports still land in DATA_PATH/bug-reports — the disk write always happened first and the webhook was only a ping about it, so nothing about the report is lost. It was a personal notification channel living in deployment config, on a platform whose owner is the only person who files reports. It was also never in .env.example: the setup script wrote a variable nothing documented, which is the same drift as PORT, in the other direction. Note DISCORD_WEBHOOK_URL is a DIFFERENT variable — the notify sidecar's own channel — and is untouched. Then a parity sweep of setup / .env.example / what the code reads, which turned up two leftovers from earlier today: HOME_DIR was still read in six files, each with its own `?? homedir()` fallback. Dead since nothing sets it, but a dead read is worse than none — it reads as a supported override. They take homedir() directly now. user-instance.ts gets a comment on why its line stays where it is: it sits above `process.env.HOME = homeDir`, and homedir() reads $HOME, so a read moved below that assignment would return whichever member was last spawned into. Two of the six had fallback chains ending in process.cwd() and '' — the second would have silently disabled whatever consumed it rather than failing. VAULTWARDEN_URL was uncommented in .env.example among the variables setup writes, though it is a plugin variable setup has never written. Commented out with the other plugin entries. The three files now agree: setup writes PORT, BROWSER_RELAY_PORT and POSTGRES_URL; .env.example lists those plus JWT_SECRET and VAULT_STORE_KEY, which are required by code and deliberately unwritten until the secret store lands. Not typechecked (empty node_modules, frozen installs). Every changed file parses; the setup section was run and writes three variables. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c5adb4aa08 |
the anthropic proxy binds PORT + 1
ANTHROPIC_PROXY_PORT is gone. It was 5051 hardcoded in four files: proxy.ts, which binds it, and three others that guessed the same constant to find it. It is the only sidecar that binds a fixed port, and that part is a real constraint rather than an oversight. Every other one binds `port: 0`, lets the kernel choose and reports back over the registration socket — which works because their consumer is the platform. The proxy's consumer is `claude`, spawned by a different pm2 process that needs ANTHROPIC_BASE_URL at spawn time and has no channel to ask what port the proxy landed on. Two processes with nothing between them have to agree in advance. So the number must be predictable, but it need not be 5051 — a value chosen against nothing, in the registered range, free to collide with anything the owner installs later. The symptom of that collision would have been chat failing while the rest of the platform looked healthy. PORT + 1 keeps the predictability and drops both the constant and the variable. Nothing to set, no second number to keep in agreement with the first, and the pair moves together when the install moves. Also corrects .env.example, which said the proxy "holds the API credential, which lives in the host env". It does not. The upstream credential is the OAuth token claude writes to ~/.claude/.credentials.json, and the ANTHROPIC_API_KEY the agent presents is the proxy's own generated secret. Verified the derivation at PORT=9000 and PORT=10000; all four consumers now import it; every edited file parses. Still not typechecked — empty node_modules, frozen installs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3c7f52ab77 |
PORT is read in one place, and it has no default
officer-url.mjs is now the only file in the tree that touches process.env.PORT.
Twenty-two others read it and supplied their own default; a value with
twenty-two sources is not configuration, it is twenty-two things to keep in sync,
and they had already drifted three ways.
It throws when PORT is unset rather than guessing. A default only covers the case
where .env was never loaded — which is not a machine anyone wants running,
because POSTGRES_URL is missing in the same breath. What the default bought was a
process that starts, binds somewhere unexpected, and fails later for a reason
that does not name the cause. Same posture as jwt.ts with JWT_SECRET.
It is .mjs, not .ts, and that is the whole reason this could be one file. pm2
launches officer-pty with node (ecosystem.config.cjs) and everything else with
bun; node cannot import TypeScript, so a .ts module would have left the pty
sidecar holding the only surviving copy of the default — precisely the thing
being removed. allowJs is already on, so the TS callers still get types. Verified
both runtimes import it, and that PUBLIC_URL-style overrides still work.
It also exports API_URL and OFFICER_API_URL, because nineteen sidecars were
independently building `ws://127.0.0.1:${PORT}` and two more were building the
http form. Those are one listener described in two protocols — no sidecar binds
anything — so they belong beside the port rather than being rediscovered per
file.
server.tsx now takes PORT as a number, so Number(PORT) at the serve site is gone.
Not typechecked (empty node_modules, frozen installs). Every edited file parses
under `bun build --no-bundle`; node and bun both load the new module; the unset
and non-numeric paths were exercised; the pm2 profile still loads.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
e72cae4830 |
one default port, and it is 9000
Every PORT fallback in the tree now says 9000. There were three answers to one question, each defensible where it was written and none of them visible from the others: server.tsx and 19 sidecars 5000 a default from before there was an installer user-instance.ts 9010 what scripts/setup-old/setup.sh really wrote .env.example 9000 what we told people to write 5000 goes first because macOS binds it — AirPlay Receiver has owned it since Monterey, so a dev server there fails to bind or gets shadowed by something that answers. All 22 sites moved together, which is the point. Changing the app alone would have turned a consistent-but-wrong default into a split one: the app on 9000 while nineteen sidecars still dialled 5000. 9010 was the interesting one. It was the only value that ever matched a real machine, because it is what the old installer wrote — and it was in the single file whose disagreement would have broken chat alone, with nothing else looking wrong. Its own comment records the same bug being fixed once already, within the file, by a change that left it disagreeing with everything outside it. Note what these defaults actually are: the sidecars bind nothing. user-instance.ts has no listener at all — it builds ws:// and http:// URLs that both address the app's single listener. So every one of these numbers is a guess at where the app is, for a value that .env always supplies. Worth removing rather than aligning, which is a separate change. Not typechecked (empty node_modules, frozen installs). Every edited file parses under `bun build --no-bundle`; the pm2 profile loads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3f071c0b24 |
the install root is derived, not configured
Seven variables out of .env. DATA_PATH, OFFICER_ITEMS_DIR and HOME_DIR are gone from the code entirely; PUBLIC_URL, PUBLIC_BUILD_ENV, JWT_SECRET and VAULT_STORE_KEY are no longer written by the setup script. data-path.ts now derives OFFICER_ROOT as dirname(process.cwd()), with data/, capabilities/ and dockers/ as fixed names under it. The direction used to run the other way — DATA_PATH from env, then OFFICER_ROOT = dirname(DATA_PATH) in app-store/paths.ts — which meant three environment variables that had to agree with each other and with the tree on disk. Eight files re-read process.env.DATA_PATH independently, each with its own `?? cwd()/data` fallback. They import the one value now, which is what made removing it safe: otherwise each would have derived its own and drifted. Three things this turned up. The cwd pin in ecosystem.profile.cjs was broken. It set `cwd: __dirname` under a comment asserting "__dirname is the repo root — this file sits beside ecosystem.config.cjs", which stopped being true when these files moved into ecosystem-files/. It walks up to the platform's package.json now, which holds wherever the file lives. That was a live bug before this change and a load-bearing one after it, since cwd now decides where the install is. assertInstallLayout joins the other two boot assertions. A wrong cwd does not error — it computes a plausible root somewhere else and writes managed homes and agent runs into it, so the install looks empty and the data looks lost with nothing naming the cause. It throws before serve(), first of the three, because a wrong answer there makes the other two check the wrong files. getOwnerHomeDir captures homedir() once at module load rather than per call. Measured on bun 1.3.10: both os.homedir() and os.userInfo().homedir return $HOME when set rather than reading passwd, and user-instance.ts assigns process.env.HOME on its way to spawning an agent. A lazy read would have returned the owner's home on the first call and a member's afterwards. data-path.ts imports only node builtins, so it is evaluated before any of that runs. JWT_SECRET and VAULT_STORE_KEY leaving .env means an install made by this script does not boot — jwt.ts throws at module load without one. That is the agreed sequencing: they move to the SQLite store (docs/secret-store.md), and writing them here meanwhile would create a second origin for a secret the store then has to be reconciled with. Said plainly in .env.example and in lib/env.sh rather than left to be discovered. Not typechecked: node_modules is empty here and installs are frozen. Every edited file parses under `bun build --no-bundle`; the profile loads and pins the right cwd; assertInstallLayout was exercised from both the repo and /tmp; the setup section was run and writes five variables. Prettier was NOT run — 3.9.6 via bunx is not the pinned resolution and reformatted unrelated unions and line wraps in six files, so those were reverted and the edits re-applied by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c5522700ff |
compare the binary as a string, and prove the uid by file ownership
host ran the live test against green. Two results. SETPRIV WORKS. The privilege drop lands on the member and the SDK spawn survives it, so the design does not change shape and everything layered on the hook stays. That was the last question that could have moved the architecture. THE BINARY CHECK REFUSED A BYTE-IDENTICAL PATH. sameFile used realpathSync, which has to stat inside a 700 home the platform is `other` to, so it threw EACCES and the catch turned that into "not their binary" — every member turn refused forever, the moment the gates moved. Failing closed was the right direction and it made the feature impossible rather than unsafe. Now resolve(command) !== claudeBinIn(run.home). Both operands are computed by the platform from the same function, so string equality establishes exactly what the check is for and needs no access to their home. sameFile and its tests are deleted: a helper kept for a case that cannot arise is a trap for the next reader. The realpath version was defending against an upstream that normalises paths, and there is no such upstream — the platform controls both ends. The fact that decides this is that the check runs in the PLATFORM process, not the member's, and neither of us stated it until EACCES did. THE TEST WATCHED THE WRONG PROCESS. child.pid is sudo, whose real uid is legitimately the service user's until it execs down through setpriv, so asserting on it fails on a working drop. Split: --version through the hook proves their binary ran, and a second spawn creates a file in their home whose owner the test stats. Nothing self-reports, and a process cannot forge the uid that owns a file it created. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9bab67aca5 |
test the privilege drop without lifting a gate
host caught a circularity I had written twice: do not lift the chat gates until a member turn has been watched running, but a member turn goes through chat and chat refuses non-owners. With the gates up there is nothing to watch; with them down the thing we wanted proven has already shipped. spawn-as-member.live.test.ts calls spawnClaudeAsMember directly against a real provisioned account — no gate, no chat, no SDK. The child's uid is read from /proc/<pid>/status, so it is the kernel's answer rather than anything the child chose to say, and it asserts >=1000 and not this process's uid: a failed privilege drop cannot pass by running as the service user. It also asserts the binary exited 0 having printed a version, which proves their install ran rather than merely being spawned, plus a negative that /bin/sh through the same hook throws. Opt-in via OFFICER_TEST_MEMBER and OFFICER_TEST_MEMBER_HOME, because it needs a provisioned member with claude installed — which exists on the production host and on no developer machine. A run without them skips loudly rather than reporting an empty file as a pass. Also adopted host's NO REPLY NEEDED terminator: "reply to everything" had no exit condition and cost the owner two agents being polite at each other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8c4f150c15 |
stop one conversation's transport hiccup from killing every session
host found this while we were elsewhere: four agent-sidecar crashes tonight, one
truncating the owner's turn mid-sentence.
error: ProcessTransport is not ready for writing
at write (…/claude-agent-sdk/sdk.mjs) <- no frames from our code
A floating rejection inside the SDK's own input pump, so no await of ours could
have caught it. With no handler anywhere in src/servers it reached the top
level, Bun exited, PM2 restarted, and every live session on the machine died —
not just the one whose transport failed.
That is
|
||
|
|
d59adbf1f2 |
scope the six sessionKey commands to their caller
The control surface half of
|
||
|
|
7cb402b25a |
chat sessions record whose they are, and refuse a mismatched caller
host found this reading 10: chat sessions carry no identity at all. state.ts
held a flat sessionKey -> transcript uuid map, the in-memory sessions Map was
keyed the same way, and websocket.ts takes sessionKey and resumeSessionId
straight off the client message.
|
||
|
|
3e0daee611 |
chmod the mcp config, because writeFileSync's mode never fires on it
host measured what
|
||
|
|
df4503180f |
stop handing a member's turn the owner's mcp config, and close the file
host found a live credential exposure while answering my question about what else the member branch missed. It was outside the diff, and predates all of it. MCP-CONFIG WAS NOT BRANCHED. mcpHostPath is module-level, written once at the owner's bootstrap, and was applied to every turn. Its env block carries OFFICER_AUTH_TOKEN, a 30-day JWT signing as the owner — so a member's turn would have spawned their MCP server holding it. Now inside the params.member ternary alongside the binary and the spawn, for the reason already written there: these values say whose turn this is and have to move together. A member gets none. What they should get instead is undecided, and undefined beats the owner's. THE FILE WAS 0644. Written with a bare writeFileSync into a 755 directory, on a host where `terminal` is granted to every role by default — so any member could cat it and hold owner-level API access on loopback. host verified that as a real member on the production host rather than reasoning about it. Now 0600. The mode is the only half of that which is code. The token has been world-readable and stays compromised until rotated, the directory chain above it is still 755, and neither is fixable from a commit. Both written up for the owner in COMMS 05, along with why I am stopping here rather than continuing: the next commit should be the rotation, not more feature work stacked on top of an open exposure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fbabc22ed7 |
wire the member branch into the SDK spawn, still unreachable
ClaudeSpawnStreamingParams takes an optional member {osUser, home}; createSession
branches on it, using their binary and spawnClaudeAsMember together, or the
owner's CLAUDE_BIN as before.
THE BINARY AND THE PRIVILEGE DROP ARE ONE BRANCH ON PURPOSE. settingSources
makes ~/.claude authoritative for settings and ~ is whatever HOME the process
gets, so pointing the SDK at a member's binary while spawning as the service
user would read the OWNER'S settings and credential while running the member's
code — and it would look like it worked.
cwd defaults to member.home before HOST_HOME for the same reason: HOST_HOME is
this process's home, so a member would start in a directory they cannot read and
the failure would present as a broken agent rather than a wrong cwd.
Nothing populates `member`. Both gates refuse non-owners before any of this is
reached, so the delta is that spawnClaudeAsMember now has two importers instead
of one, and neither path a user can take changes. Verified rather than assumed,
since host made it a condition: both gates intact, 84 tests pass.
Not authorization: host gave an opinion on wire-first and deferred to the owner,
who has not ruled. Corrected in COMMS, where 01 had overstated it. The gates
come off on the owner's word alone; this reverts as one commit if the answer is
no.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
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> |
||
|
|
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
|
||
|
|
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>
|
||
|
|
cdee320fed |
stop orphaning opencode turns and serves on restart
B8, both halves. They share index.ts, so they share a commit. In-flight turns: `opencode run` is spawned, not supervised, so pm2 restart officer-opencode left every turn ALIVE — reparented, still spending tokens, still writing files as the agent, with the only reader of its stdout gone. The transcript stopped mid-tool-call, which reads as the agent hanging. stopAllOpenCodeTurns kills them and settles each synchronously, because the caller is about to process.exit and nothing waiting on proc.exited would ever run. Settling writes a reason, so a reload after a restart explains itself instead of trailing off. Turns are stopped BEFORE the connection is destroyed — that write travels over it — and the flush is bounded, since losing the explanation is bad but hanging the restart is worse. Stale serves: the sweep read /proc, so it was a no-op on macOS and orphaned serves piled up, one per unclean exit, each holding a port. Added a pidfile sweep alongside it. A pid we wrote ourselves needs no cwd guard to prove it is ours, which is the part ps cannot answer portably (macOS would need lsof), and a serve started by hand is never in the file. The guard checks command AND subcommand: matching the word serve anywhere in the line would sweep a running turn whose prompt merely mentioned it. Fixtures are real ps output from both machines, not invented. Split into serve-sweep.ts because index.ts spawns a serve at module scope, so a test importing it would start one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5a7591124a |
resolve live sessions by claude's id, not the agent's session key
Every live row read 'Starting…' and linked nowhere, because the title lookup searched for a transcript named after the session KEY. It isn't. The key is officer's handle for a conversation; the transcript is named after Claude's own session id, and the mapping between them exists only inside the agent (setClaudeSession/getClaudeSession). I assumed the two were the same and never checked — confirmed wrong by looking for the ids from the officer log under ~/.claude/projects and finding nothing. claude:list now reports claudeSessionId beside the key, the route resolves titles by that, and rows link to it. Null means the first turn has not reported one yet, which is a genuinely unwritten conversation and stays unlinked. Needs the agent sidecar restarted to take effect — the new field comes from there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
975673a9a6 |
don't let the agent die when postgres blinks
resolveOwner already retried forever — but only when the query SUCCEEDED and returned nothing, which is a fresh install waiting on bootstrap. A query that THREW escaped the function, rejected the top-level await and exited the process, into exactly the PM2 restart loop its own comment says it exists to avoid. So any Postgres restart (57P03 'the database system is starting up') or moment of unavailability killed every live agent session on the machine and spun the sidecar until the database answered. That is what took a session down on 2026-08-10, and why this process showed 468 restarts against 0 for every peer that starts without needing the database. The loop now catches as well as checks. Still retries forever, matching the case beside it: a database coming back is a matter of time, and an agent that gave up would need a human to notice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a75bf7a283 |
ask the agent what it is still running
Officer's session records are in memory and die with `pm2 restart officer`, while the agent is a PM2 peer and keeps generating. `adoptOrphanedSession` rebuilds a binding — but only when a browser reconnects to a session *by id*, which you can only do if you already knew the id. So a session that survived a restart was invisible, and nothing could answer "what is running right now". `claude:list` returns each live session with `isGenerating` and `pendingTasks` — the same two fields the agent's own `armIdle` consults before collecting a session, so a caller can tell "busy" from "merely open" the way it does. Surfaced as `GET /chat/live`, which sits beside `/chat/sessions`: those are transcripts on disk, these are the ones with a process behind them. `getActiveSessionKeys` is replaced rather than joined. It returned bare keys, could not distinguish a session mid-turn from one merely open, and had never been called by anything. `listLiveClaudeSessions` fails toward EMPTY, where `isClaudeGenerating` beside it fails toward alive. The asymmetry is deliberate: not knowing there means leaving a spinner up, and not knowing here would mean inventing sessions. Step 2 of docs/chat-session-lifetime.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b953a6ba8c |
agent: stop the proxy hop capping chat sessions at 200k context
Platform chat ran at 200K while the same `claude` in a terminal got Opus 5's
full 1M. Nothing to do with the model, the account or compaction tuning — the
CLI gates 1M on `provider === 'firstParty' && Fp()`, and `Fp()` is satisfied
only when ANTHROPIC_BASE_URL is unset or its host is api.anthropic.com. We
point it at 127.0.0.1:5051 so the agent's traffic goes through the OAuth
proxy, which fails that host check and silently drops the window to 200K.
_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL is the CLI's own escape hatch for a
first-party passthrough, and the assertion holds: the proxy forwards verbatim
to api.anthropic.com and already preserves anthropic-beta.
Read from the CLI binary (2.1.223), not inferred: claude-opus-5 carries
context:{window:1e6, native_1m:true}, so the model half of the gate always
passed. Only the hostname was wrong.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
33d96f19ee |
end a turn that has gone silent, instead of generating forever
A turn could stop producing events and stay `isGenerating` indefinitely. Nothing covered it: the idle timer answers the opposite question — how long a session with NO turn in flight may sit before collection — and every client showed a spinner with no timeout of its own, so a wedged turn presented as a chat that was still thinking. On 2026-08-08 one ran for seventeen minutes inside an auto-compaction, reached over the socket to an iPad, and was indistinguishable there from a dead app. The compaction is silent by design (the PreCompact hook is the only announcement, and the code's own comment allows 2.5 minutes), so there was nothing to distinguish it from. A stall watchdog now rides every emitted event: any sign of life pushes the deadline back, and expiry ends the turn the way a real failure would — isGenerating off, idle re-armed, and an `error` the client can render. The agent process is deliberately left alive, since it may still be working and the next turn resumes it; what this guarantees is that the client is TOLD, which is the part that was missing. The budgets are generous rather than tight — ten minutes of silence normally, twenty while compacting, re-armed from the PreCompact hook because that hook fires as the long silence begins and the deadline the turn is holding was sized for ordinary work. Killing a turn that was about to succeed is worse than the hang this prevents. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6089eb18fa |
read Claude's OAuth from the macOS Keychain, and keep it ahead of expiry
On a Mac, Claude Code stores its credentials in the login Keychain and never writes ~/.claude/.credentials.json — the only file this proxy knew how to read. The workaround was to copy the Keychain blob into that file by hand, which is a snapshot: a refresh ROTATES the refresh token and revokes the previous one, so the two stores were not redundant copies but competitors, and whichever refreshed second got `401 OAuth access token has been revoked`. That is not hypothetical. On 2026-08-08 it took out every chat turn from the iPad for six hours while the terminal CLI beside it worked fine — the harness spawned, retried for three minutes and wrote the 401 into the transcript, which from the app looks like an agent that simply never answers. So on darwin the Keychain is the authority and the file is a mirror, holding the same token rather than a different rotation of it. Everywhere else — every Linux server — the file is still the authority and nothing changes. Detection is process.platform, and a machine with no `security` binary or no such item falls through to the file rather than failing. Three recoveries, cheapest first: - a watchdog checks every 30 minutes and refreshes when under an hour remains. It checks rather than refreshing on a blind schedule because each refresh rotates the token, so a needless one is another chance for the stores to disagree. - an upstream 401 now RE-READS before refreshing. When a token has genuinely been revoked the machine usually already holds a good one, because Claude Code refreshed it into the Keychain minutes ago; spending our own refresh token there is what caused the divergence in the first place. - only if nobody else has moved do we refresh ourselves. The Keychain write goes through argv, which is the only non-interactive form `security` offers, and matches on the service AND account pair — the account is read off the existing item rather than assumed, or the update would silently create a second entry instead of replacing the one Claude Code reads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d56be0301d |
retire the single-user claim from the docs it outlived
CLAUDE.md asserted "single-user is a hard invariant, not a stage" while users held six rows and role_capabilities held grants. Every doc that repeated it is corrected here, in prose and in the code comments that carried the same claim. The accurate statement is narrower: one owner who bypasses every check, other accounts holding only what their role is granted, and a set of capabilities — terminal, chat, files, tasks, items, desktop, browser — that are structurally ungrantable because they execute as the owner's OS user. TODO.md gains a Multi-user section for what the read turned up: no way to create a second account, dashboards.id colliding across users, authorize.ts untested, pty/vault/opencode taking no identity, Radicale still owner_only. claude-sidecar-isolation.md's open question is answered rather than left open — the per-email spawn model is dead weight, because chat is an execution capability and no second account can ever reach it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6b4339052a |
reattach a refreshed browser to a running turn
Refreshing mid-turn appeared to kill the agent's output. It never did: the session survives a dropped socket, the agent keeps generating into it and keeps committing durable events, and `close` only detaches the socket and arms an hour-long idle timer. What broke was purely delivery — and the reconnect path that would have fixed it could not fire, because the browser came back having forgotten officer's session key. It lived in page state. The only id left was Claude's transcript uuid in the URL, and nothing accepted that. So accept it. `attach` carries the uuid, and the agent's on-disk session map — the single record relating the two — turns it back into the key everything else is written in terms of. The uuid now also goes out at `system.init` rather than only at `result`, which is what makes the first turn recoverable at all: until now a chat had no address until it had finished, and a long first turn is exactly the one worth reconnecting to. `sync:live` deliberately carries no messages. The harness writes its transcript as it goes, so the HTTP load on landing already supplies the past; sending the server's record of the same messages on top of it would duplicate them, and there is no shared id to reconcile the two by. Attach hands over the rest of the turn, the half-written paragraph the transcript cannot hold, and the session's cursor head — that last one so a *later* drop replays from the head instead of re-delivering the whole conversation from zero. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
969b2f3762 |
show compaction as it happens and open tool calls while they run
Compaction was the one thing the harness does that emitted nothing at all while it ran, and it can run for minutes — silence that reads as a hung turn, which costs a server restart to discover it wasn't. The sidecar now reports both ends: the start from the PreCompact hook, the finish from the compact_boundary message with the token count, both durable so a reload or a reconnect still sees them. Tool rows open themselves while they run and hold for five seconds after their result, so the inputs are on screen at the moment the call is made rather than after the fact. The clock lives outside React, keyed by tool call id: rows are virtualised, so unmounting is not the call ending, and a fast call can render its start and its result together — a row that only opens when it catches the pending state never opens for exactly the quickest calls. A click outranks the clock for as long as the row lives. A failure behaves identically and differs only in colour, so it stays findable by scanning and nameable in conversation. Shell logs move off the green-on-black pre onto the shared code surface, which is the one block that had no copy button and the one you most often want to hand to someone else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6d0d103c78 |
carry attached images through to the model
The composer already uploaded an image, split its data URL and put the bytes on the wire as `images`. Nothing on the server read them. The `chat` ClientMessage had no such field, and the prompt reached the sidecar as a bare string, so all the model ever saw was the client-generated `[Attached image: …]` placeholder — a label describing a picture it was never shown. The transport was never the obstacle: `query()` consumes an async iterable of user messages whose `content` is an Anthropic `MessageParam`, and only `pushTurn` hardcoding a string kept it to text. So `images` is threaded through the four hops that dropped it and turned into native image content blocks at the end, renaming `mediaType` to the API's `media_type` at that last step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
876b39b301 |
end a turn whose agent has gone
restarting officer-agent takes every persistent session with it and nothing downstream notices: the browser's socket is healthy, officer's subscription is a bus filter, and there is simply never another event. the spinner ran forever and a refresh didn't help, because the transcript has no ending to read. keyed off the agent *registering*, not disconnecting — a disconnect fires on every `pm2 restart officer`, when the turn is fine. a registration socket dies with its process, so an agent appearing on it is a new one. covers the sitting tab; the reconnect path covers the rest, with the client now sending its belief that a turn is in flight and officer checking it against the agent over a new claude:is-generating. the check fails toward alive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
29fc9722c1 |
interrupted by user, not an error
pressing stop ended the turn with "Claude Code returned an error" — the agent sdk reports interrupt() as an ordinary failed result, indistinguishable from a real fault downstream. the sidecar now flags the session it interrupted and rewrites that event to the existing durable 'stopped', which opencode already emitted. escape stops the turn (bound to the chat subtree, not the document), and the prompt comes back to the composer verbatim unless you've started typing something else. history parity: claude files [Request interrupted by user] as a user message, so the transcript reader maps those exact strings to the same role instead of replaying them as something you typed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f4be4fd431 |
write the claude session id through instead of debouncing it
the sessionKey to claude uuid map is what --resume needs to reattach a conversation after the agent sidecar dies, so a 30s debounce put exactly the wrong state behind a window. flushAndSave on SIGTERM covers a pm2 restart but not a crash or SIGKILL, which is the case resume exists for. an equality guard keeps onSessionId from thrashing the file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b21cfc2376 |
clean out the per-container architecture's remnants
The first iteration gave every user their own Docker container: the user's whole
world lived inside it, and only the super admin could see the real filesystem.
That model is gone, but its scaffolding was still in the tree, and it had already
cost time today — the /usr/local/bin/claude symlink removed a few commits ago
existed only because the bwrap jail ro-bound /usr and could not see the
installer's target.
Deleted:
generate-container-context.ts built the CLAUDE.md and settings.json that told
an agent what its container looked like. Its
only importer was the provisioning removed in
the previous commit, so it had zero consumers.
getUserPiConfigDir pointed into the managed container home. No
consumers anywhere in the tree.
Renamed:
DATA_PATH/<email>/.container-context -> agent-config. It holds one file, the
MCP server config handed to the CLI, and has nothing to do with containers. The
path is written and consumed through a return value, so nothing else reads it;
an old directory left on disk is inert.
Documented rather than removed, because both still have live callers and pulling
them out is a refactor rather than a cleanup:
getHomeDir the container's home. Nothing executes there now — terminals,
chats and task runs all use getOwnerHomeDir — but it survives
as that function's fallback and in pipeline-executor.
toShellUsername named for deriving a Linux username inside the container,
32-char limit and all. Nothing creates a Linux user now; the
value ends up only as a claim in the signed task token, so it
is a sanitiser wearing an old name. Unpicking it means
changing that token and WSData.
Nothing to clean on disk: DATA_PATH/<email> has no home/ tree and no
.container-context/. The docs that still mention any of this are the two marked
"Historical" at the top, which are records of what was true then and should keep
saying so.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
6b50ad978e |
merge the macos branch: setup script, mac pm2 ecosystem, claude binary resolution
Three files, all additive — nothing on master is modified by this beyond the CLAUDE_BIN change below, and no file is deleted. The branch predates master by about 180 commits, but it touches nothing master has touched since, so the merge is clean. The part that matters beyond macOS is claude-manager.ts. CLAUDE_BIN was pinned to /usr/local/bin/claude, which dated from the bwrap-sandboxed architecture: the jail ro-bound /usr and saw nothing else, so the installer's real target (~/.local/bin/claude) had to be symlinked somewhere the sandbox could reach. That sandbox is gone, and the hardcoded path left the sidecar unrunnable on any host without it. It now resolves an explicit CLAUDE_BIN pin, then PATH, then the locations Anthropic's installer actually writes to — mirroring how OPENCODE_BIN is already resolved in the opencode sidecar. ecosystem.mac.config.cjs is deliberately a trimmed set of processes rather than a mac port of the full ecosystem. It is also stale in two specific ways, left as-is here and worth fixing separately: it names officer-claude, which master renamed to officer-anthropic-proxy, and its officer-pty runs src/servers/api/terminal/pty-sidecar.mjs, which moved to src/servers/sidecar/pty/index.mjs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7b6ca5f4ca |
attribute subagent output to the task that spawned it
the harness stamps every message a subagent produces with parent_tool_use_id. the sidecar wrote it outgoing and nothing ever read it coming back, so a subagent's prose and tool calls were spliced into the main transcript as if the agent you are talking to had produced them — and worse, its deltas were appended to the same text buffer, so two voices were concatenated inside one bubble. both buffering layers (stream-parser's textBuffer and turn-stream's buffer) are now maps keyed by parent, and parentToolUseId rides on ChatEvent, ServerMessage and Message. useChat nests parented output under the Task row that spawned it; ToolActivity draws the trace inside the expanded panel. background tasks get the same treatment from the other end: task:started and task:notification were two unrelated fake assistant bubbles minutes apart, and are now one role:'task' row correlated by taskId that appears pending and resolves in place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
47d03de307 |
chat: carry claude's own session uuid on the result event
The sidecar knew which transcript file a turn had landed in and kept it to itself — setClaudeSession fed --resume and nothing else. A session officer started was therefore unaddressable from the platform side. Put it on the result event so it crosses the wire. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
71e39b7639 |
move opencode's turn output into its own sidecar
The second copy of the same problem. The opencode sidecar reported raw ChatEvents and officer translated them, buffered the assistant text and wrote every durable message to chat_session_events — so an officer restart mid-turn lost whatever the model had produced since the last write, and `connect.ts` dropped the events that arrived while it was down without a word. Both harnesses speak ChatEvents, so the sidecar reuses the agent's session log verbatim: translate, commit, then deliver the finished message with its cursor id as `opencode:message`. Officer folds it into the in-memory transcript and relays it, exactly as it now does for claude — `createEventHandler` (166 lines, a duplicate of turn-stream.ts) and `emitToSession` are gone, and nothing in officer writes to chat_session_events any more. `opencode:event` stops being a wire event; it is the runner's internal report to the sidecar it runs in, typed as such so it cannot leak back onto the socket. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
238c3b8097 |
make the agent sidecar the writer of record for chat output
officer's registration socket silently drops sends when it isn't OPEN (sidecar/connect.ts:send — no queue, no error, no return value). the agent pushed raw parser events over that socket and officer translated and persisted them, so everything a turn produced while officer was restarting went nowhere: the turn kept running, the output was gone, and a reconnecting client replayed a log that simply had no rows for those seconds. stage 1 kept the agent alive across a restart; this is what makes its output survive one too. move the translation and the write into the sidecar: - turn-stream.ts is the stateful ChatEvent -> browser-message translator lifted out of websocket.ts (delta buffering, flush before tool:start and result). pure and synchronous, so it is unit tested — 12 tests, 100% lines. - session-log.ts commits each message to chat_session_events and only then hands it to officer, with its cursor id attached. per-session promise chain: translation is synchronous and therefore in arrival order, and only the commit is queued, so cursor ids are assigned in the order events actually happened. a delta that overtook the assistant:text in front of it would make the client commit its stream buffer at the wrong point, so deltas go through the same queue even though they are never written. - claude:event on the wire becomes claude:message: a finished browser-facing message plus its seq. officer relays it verbatim and folds it into the in-memory session for sync:messages. it no longer builds or persists chat messages for this harness. gap detection, which is what the durable log is for. chat_session_events.id is a global bigserial, so two consecutive events of one session are not consecutive ids and a client cannot tell a contiguous replay from one with a hole in it. each durable message now carries prevSeq — the cursor of the previous message in the same session — which is inside the persisted payload, so it survives replay. useChat compares it against the cursor it holds before advancing, and surfaces a visible marker on a mismatch: a conversation that silently skips a tool call or half an answer reads as the assistant having done something inexplicable. only checked once a cursor exists, because opening a session from history legitimately starts mid-chain (events are swept after 7 days, the transcript is not). a failed write delivers live with no seq, so the client sees the message but does not advance past something it cannot replay, and the next successful write chains from the cursor the client still holds. pipeline steps pass durable: false. their sessionKey is a throwaway uuid no browser will ever replay and the job's own event log is its record, so writing those rows only grows the table. opencode still goes through officer's createEventHandler, now labelled as such. that is the sidecars-opencode branch. this fixes R4 from CLAUDE_SIDECAR_ISOLATION.md. R3 and R5 already worked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
62dc4c1a5c |
run the agent as a pm2 peer instead of a child of officer
the process that runs claude (sidecar/claude/user-instance.ts) had no pm2 entry and was spawned on demand by the main server, with stdout/stderr inherited. that made every agent session a grandchild of officer, so pm2's tree-kill took the session down on every `pm2 restart officer` — the single thing that makes it impossible to work on the platform while an agent is running. give it its own entry (officer-agent) and delete the spawn machinery: ensureClaudeSidecar, spawnAndWaitForRegistration, the 50ms registration poll and the per-email claudeProcs/claudeSpawnWaiters maps, ~77 lines. officer now spawns no sidecar at all. for that to work the sidecar had to stop needing officer to start: - it resolves the owner from the database (getOwnerUser) instead of reading CLAUDE_USER_EMAIL out of the env officer built. single-user is a hard invariant, so there is nothing to fan out over. CLAUDE_USER_EMAIL still wins when set, for manual runs, and a fresh install waits for bootstrap rather than exiting into a restart loop. - it reads the anthropic proxy secret from the proxy sidecar's own state file rather than being handed it in env. lazily, because ensureProxySecret persists on a 30s debounce and pm2 starts both processes together. it registers as 'agent' with capability 'claude', so the registry finds it the way it finds every other sidecar. that removes the email argument from killClaude, interruptClaude and clearClaudeSession, which only ever existed to locate a per-email sidecar by name. what officer keeps is a short wait-for-capability, because pm2 brings peers up together and the first request after a boot can beat the sidecar's registration. also align the two officer port fallbacks in the sidecar (5000 for the socket, 9010 for the rest base) — same instance, so they cannot disagree. this fixes R1 and R2 from CLAUDE_SIDECAR_ISOLATION.md. events produced while officer is down are still lost; that is stage 2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
75ce4ad44f |
resolve the claude cli instead of hardcoding /usr/local/bin/claude
the path was pinned to /usr/local/bin/claude for the bwrap-sandboxed architecture: the jail ro-bound /usr and saw nothing else, so the installer's real target (~/.local/bin/claude) had to be symlinked somewhere the sandbox could reach. that sandbox is gone, and the constant outlived it — the sidecar could not run anywhere the symlink was absent. a stock macos host has no /usr/local/bin at all. resolve at module load instead: CLAUDE_BIN pins it explicitly, else whatever is on PATH, else the locations the installer writes to. same shape as OPENCODE_BIN in the opencode sidecar. the resolved path is logged at startup. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
621f93b884 |
chat: don't idle-GC a session while background tasks are still running
Phase 1 gap surfaced by a long overnight job: armIdle fired 30 min after the
last turn regardless of in-flight background work, so a silent run_in_background
job outliving the timeout got its persistent SDK session aborted — killing the
harness that delivers its task_notification (and any detached watcher's hook).
Fix = task-lifecycle heartbeat: track task:started → task:notification per
session; suppress/re-arm the idle timer while any task is pending. task:started
also clears a pending idle timer. So long run_in_background jobs keep their own
session alive and their completion is delivered; idle-GC resumes only once all
tasks finish and the session is truly idle. Pairs with the Activity path-tail
(
|