there were four doors, not two. the ws upgrade in server.tsx and the
vault notifications socket each verified the jwt themselves, so a key
that worked against /api would have 401'd on cliamp — signed in and can
play audio would have been two different questions for the music app.
both now call resolveAuthToken. verified: owner key upgrades cliamp
(101), bogus key 401, member key 403 on terminal exactly as their jwt
is.
reset-password and verify-token deliberately keep verify() — they read
a purpose-scoped reset token and a key must not be spendable as one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a user can mint a long-lived key for an app or a device instead of
carrying a 30-day session, so multiple logins on the mobile apps are
per-device revocable rather than one shared token.
identity was being decided independently in userMiddleware and
originScopeMiddleware, each verifying the token itself. teaching only
one of them a new credential format is how those two stop agreeing, so
both now call resolveAuthToken and neither knows what a bearer string
is. verified: a member's key returns the same status as their jwt on
every route tried, 403s included.
a key carries its holder's full authority — not an escalation, it
equals what the password could already do. scoping wants a scopes
column, not a change here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Extracting resolveNotifyUser into its own module immediately caught a hole
in the fix from the previous commit: a header that was present but
unparseable fell through to the body, so a browser could send junk in the
header, name any user in the body and win.
PRESENCE of X-Officer-User is the signal, not its validity — a malformed
header means a proxied request went wrong, and falling through hands the
decision back to the caller we just declined to trust.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
reset-password accepted any valid signed jwt as a reset token, including a
30-day session token — its sibling verify-token.ts already gated on
purpose === 'reset-password' and this handler did not. forgot-password mints
that claim, so the gate costs the legitimate flow nothing.
notify's DELETE /_officer/devices/:token deleted by token with no user
predicate: a token is the address of a device, not a secret, so any account
holding the notify capability could deregister another's device.
deletePushDevice now takes an optional userId — the route passes it, the
APNs/FCM dead-token paths deliberately do not.
POST /_officer/notify let a request body's userId override the
proxy-injected X-Officer-User. The header now wins where present, which is
what separates a signed-in browser from a loopback producer that has no
session to speak from.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Committing work that was left uncommitted in the shared tree. I did not write it;
I reviewed it in full, verified it against the running system, and am landing it at
the owner's explicit request because no one currently owns it.
This REPAIRS master. `useAgentPanel.ts` shipped in dbe585f and calls
`/chat/agent-panels`, but `registerAgentPanelRoutes` existed only in the working
tree — so on master as pushed, every one of those calls 404s. The feature has been
half-landed since that commit.
What it is. A panel on a dashboard can be given a name ("frontend", "code-reviewer").
Naming it mints two things: a `sessionKey`, which is the panel's permanent continuity
(it keys the sidecar's on-disk resume map and `chat_session_events`, so the same panel
reopens the same Claude session), and a `handoffToken`, a bearer credential scoped to
exactly one verb. The agent in that panel is then addressable by name, and can pass
work to a peer on the same dashboard over `/api/agent-handoff`.
Three doors, deliberately separate:
- `/chat/agent-panels` (browser, session-authed) — name / list / rename / forget.
Mounted on the chat router rather than given its own prefix: these routes create
and name Claude sessions, which is authority `chat` already grants. A second
top-level mount would have meant a second capability entry claiming the same
thing under a different name.
- `/api/agent-handoff` (agent, token-authed) — peers and send. Unprotected by the
session middleware and exempted in `capabilities/totality.ts` with its reasoning
written down, because the caller is a subprocess with a token, not a browser with
a cookie.
- The transcript stays where transcripts live. DELETE forgets the address and the
panel's claim on the session; it does not touch ~/.claude/projects.
Security, as verified rather than assumed:
- The sender is derived from the token, never from the request body — there is no
`from` field on the wire, so it cannot be forged.
- Every lookup is scoped to the token's `userId` AND `dashboardId`, so an agent can
only see and reach peers on its own dashboard.
- `toAgentPanelView` strips `handoffToken` and `userId`, and it is the only shape
the browser routes return. Confirmed by reading every return path.
- Live-tested: a real token on `GET /api/agent-handoff/peers` returns 200 with
correctly scoped peers; a bogus one returns 401.
Two judgement calls in the code worth knowing about, both already commented at their
site: the introduction turn inlines the handoff token into a runnable curl (a
single-owner MVP trade), and `agent_panels` carries no FK to `dashboards.id` because
that primary key is mid-rework to a composite.
Schema uses `uniqueIndex` throughout, never `unique().on(...)` — the rule that exists
because drizzle-kit mis-diffs named composite unique constraints and re-creates them,
which is what wiped seven tables on 2026-08-03.
NO `bun db:push` IS NEEDED. `agent_panels` is already live in Postgres with 6 rows;
the schema file is catching up to a database that already has it.
Verified: `bunx tsgo` clean, `bun test` 538 pass / 0 fail across 35 files.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The harness delivers a finished background task to the agent by writing it as the user's next
message, so Claude's file holds a raw <task-notification> envelope as a user turn. Live it never
shows, because the same event travels separately as task:notification — it appeared only when a
refresh rebuilt the conversation from the file, as a bubble on the owner's side he never typed.
Same defect as INTERRUPTION_MARKERS and the same fix. Anchored to the start of the message so
quoting one inside a real message stays yours. Also skipped when picking a session's title, where
it is no more a title than a slash command is.
Verified against a live transcript: 38 user bubbles before, 32 after, the 6 removed being exactly
the notifications.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
adding an email account failed with a bare "failed to add account" toast. three
defects stacked, each hiding the next.
the email sidecar's http.ts reconstructs what the platform's middleware used to
provide, but only did two of three — bodyParser was never remounted, so every
write route read ctx.get('body') as undefined and POST /accounts threw on
body.provider before ever reaching the credentials.
its onError then read `.status` off the thrown custom-error, which carries
`statusCode`. every deliberate 4xx fell through to the 500 branch and had its
message replaced with "internal error", so a rejected IMAP login and a genuine
crash looked identical. it also answered JSON where the rest of the api answers
errors as plain text. now mirrors hono.ts's handler rather than inventing a
second shape.
useClient threw a plain object, so the ~33 sites narrowing with
`err instanceof Error ? err.message : <fallback>` always took the fallback and
discarded the server's message. now throws an ApiError subclass keeping both
status and message, so those sites start surfacing real errors.
only email reads ctx.get('body'); every other sidecar is a pure proxy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A task row is officer's own invention, synthesised from the harness's system.task_started, and
nothing corresponding to it is ever written to Claude's transcript. So rebuildTranscript can only
produce user/tool/assistant rows, and sync:live deliberately carries no messages — which left the
background-task tray empty after a mid-task refresh even though the work was still running.
Fold the durable log on attach into started-minus-notified and hand that back on sync:live. The
same read now supplies the cursor, so this costs one query rather than two. Finished tasks are
excluded: replaying those would resurrect rows already seen to resolve.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
/plans/:name, no redirect guard: the bare route is 'no plan open', which is a
real state, so the auto-select-first effect is deleted rather than turned into
a Navigate. The picker stays a native select — chrome for one document, not a
master list — but it navigates instead of setting state.
Reading the server route for this turned up a path traversal: hono
percent-decodes params, so GET /api/plans/..%2F..%2Fsecret reached
join(plansDir, '../../secret.md'). basename() the param.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dashboard-state cache had staleTime: Infinity and there is no invalidateQueries anywhere in the
repo, so it was fetched once per page load and never again: two windows diverged permanently and neither
was ever told. It now refetches on focus — with three non-default guards, because this cache is
optimistic and a refetch that started before an in-flight PATCH landed would overwrite the value we
already showed. Never on mount (splitting a panel mounts a fresh consumer, which is exactly when a write
is in flight), never on reconnect, and on focus only after a short quiet period with nothing in flight.
The PATCH stopped assembling a full state blob it then returned to nobody — three SELECTs per splitter
release, thrown away, and a caller that did read it would be reading state assembled before whatever
concurrent write it raced.
And the last three `.catch(() => {})` in this family are gone: dashboard create, rename and delete build
their own multi-key patches and so bypass the hook. They now go through persistDashboardState, which
keeps the in-flight bookkeeping honest and, on failure, invalidates rather than reverts — there is no
single previous value to swap back once the roster has been rewritten, and a refetch is the only thing
that makes the list agree with the server. A failed delete used to leave the dashboard gone from the list
and alive on the server, reappearing at the next reload with no hint why.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four defects, one shape: a write that returns 200 and lands nowhere.
- Unknown keys were dropped by a chain of `if (match) continue` with no `else`. The three prefixes
CommandTerminalWrapper actually writes — tmux, nvim, claude-code — were among them, so those panel
maps lived in the React Query cache only: every reload minted a fresh uuid and abandoned a running
pty. They now live in a `panel_state` bag on the dashboard row, and an unmatched key 400s.
- `ws-terminals-{id}: null` fell through to an upsert, writing NULL into a NOT NULL column on a live
dashboard and re-INSERTing a deleted one. Renaming a dashboard sends exactly that, paired with
`ws-layout-{id}: null`, so the old slug came back as a zombie row in the dashboards list.
- HostTerminalWrapper and CommandTerminalWrapper built their state key straight from `dashboardId`,
which is a workspace *key* (`ws-layout-<id>`), while TerminalWrapper stripped the prefix. The server
read the un-stripped form back as a dashboard id and created it. One rule now, in state-key.ts.
Verified against the live server: unknown key 400s, the three prefixes round-trip, a null on a live
dashboard is a no-op, and the rename sequence leaves no zombie.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
every multipart upload through /api/<sidecar>/* arrived corrupted. bodyParser ran on
proxy routes and called parseBody for multipart, so hono cached a FormData on the
request; when the proxy then asked for the bytes hono re-serialised them from that
cache with a NEW boundary, while the proxy still forwarded the ORIGINAL content-type
header. header and body disagreed and the far side rejected it with
"Multipart: Unexpected end of form".
bodyParser now skips prefixes owned by createSidecarProxy, which register themselves
so a new sidecar cannot forget. the proxy also forwards the body as a stream instead
of buffering it, which drops the second in-memory copy of every upload.
note the bug report proposed skipping multipart in bodyParser outright; that would
have broken /upload, /file-browser upload and /bug-report, which do read a multipart
body from ctx.get('body').
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
the cut-off notice is its own event now rather than an error: nothing is broken
and nothing is lost but the turn, so the row says what happened and offers the
one action that fixes it. the conversation is already durable — the claude
session id is written through to disk and passed back as resume: — so retry
just resends the prompt on a session the fresh agent picks up with full
context. read back out of the transcript, so a second window on the same
session can offer it too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
signin refused any non-owner arriving through the web or mobile platform
origin: "This account can only sign in through its app." so a member could
hold a gitea grant and still never reach a page — verified as a live 403
before this change.
that rule was correct while single-user was the invariant. the only
non-owner accounts were music-app accounts, and there was no way to say
"this person may use the platform, but only these parts of it", so keeping
them out entirely was the honest answer. capabilities say exactly that now,
per feature, at both doors and on every request.
so superAdminOnly is retired rather than patched. the web origin and the
platform app get no path scoping — what their caller may reach is decided by
their role, not by their Origin. per-app path scoping stays for the
single-feature apps (music, vault, tail), where it still means something.
note this WIDENS who may sign in: any Active account can now authenticate
through the browser. that is the intended product change, and it is only
safe because the capability backstop runs on every request behind it —
which is why it lands after that, not before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
the server half of the previous commit, which belonged with it. the frontend
guard needs both lists: absence from `routes` cannot tell a route this
account lacks from a route no capability claims, so without this the guard
permits everything.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GET /api/user/capabilities is what the caller may reach, and every account
may ask — it is mounted on a core capability so an account granted almost
nothing can still find out what it has. the dock and route guards read it.
it is a courtesy, never enforcement: hiding an icon is not access control
and the 403 in origin-validation stays the lock.
GET/PUT /api/users/capabilities edit the policy, owner-gated. the write path
is where the registry's authority over capability keys is applied, which is
why the column has no CHECK: unknown keys and non-app kinds are refused
rather than stored for the resolver to drop on read.
the exception is `selfService`. useAuth calls PUT /api/users to change your
own name and avatar, and that route has always lived on the same router as
the owner-only account administration around it — so declaring /users an
admin capability locked every member out of their own profile. moving it to
/api/user would be tidier and would break every shipped mobile client, so
instead the registry says out loud that this one route is not what the
capability around it is. exact method and exact path, so it cannot widen:
verified that PUT /api/users passes while GET /api/users, PATCH
/api/users/:id/role, DELETE /api/users/:id and PUT /api/users/:id are all
still refused.
23 unit tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
replaces the account backstop. it was two hand-written lists — NON_OWNER_PATHS
confining every non-owner to /api/auth + /api/music, and NON_OWNER_WS_PROVIDERS
doing the same for sockets. they were not wrong, they were unscalable in one
specific way: an allow-list answers "which paths" but never "why", so onboarding
anyone who needed anything other than music meant editing an array in a
middleware file and hoping the socket half got edited too.
now both doors resolve against the registry, so they cannot disagree about what
a role holds. terminal, chat, task-runner, pipeline and desktop are refused by
being `execution` capabilities rather than by being absent from a list somebody
maintains.
fail-closed everywhere: an unknown capability key, a missing row, a database
error or a deleted user all deny. the grant cache is keyed on role and has an
explicit invalidation contract — unlike the one super-admin.ts refuses to have,
this one has exactly one writer and it lives beside the reader.
seeded Member → music at WRITE, which is precisely what the old path-based
backstop allowed. granting `read` would have been a silent downgrade that broke
playlists for the three live member accounts overnight.
verified against the live database and real accounts: 27 http/socket cases, the
read/write split (personal sub-paths writable at read, /music/scan not), cache
invalidation after a revoke, and the borrowed test account's role restored.
20 new unit tests; full suite 362 pass 0 fail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
permissions are capabilities, not routes. a capability is a feature — the
unit the owner grants, the dock filters on and the acl enforces — declaring
the api prefixes, websocket providers and screens it expands to.
four kinds. core is every account and is not grantable because it is not
deniable. app is the grantable surface. admin is the platform administering
itself. execution is never grantable at any level: terminal, chat, tasks,
files, desktop and browser all run as the owner's os user in the owner's
home, so granting one is co-ownership of the machine rather than a feature.
the part that matters is assertCapabilityTotality. the websocket hole fixed
in 2873948 was not a wrong rule — it was a door added without telling the
rule, because bun's route table matches /api/terminal/ws before the /api/*
catch-all that reaches hono's middleware. so the server now refuses to start
unless every mounted prefix and every user-facing socket maps to exactly one
capability. hono.ts mounts from a table and exports it, so the check reads
the real surface instead of a copy that can drift from it.
verified: passes against the live surface, and refuses all four ways — an
ungated router, an ungated socket, a claim on a deleted router, a claim on a
deleted socket.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The list collapses each chain to its newest link — the only one that can be
resumed — carrying the root's title and start time, the summed message count
and a part badge. The detail splices the chain's transcripts oldest-first with
a divider between parts, server-side, so the client's index-window pagination
needed no change.
The divider says "context cleared — nothing above this is in memory", because
the whole risk of merging is that the history reads as continuous when the
agent's context is not. Delete cascades the chain and the confirm says how many.
Supersedes the "continues X" line from the previous commit: there is nowhere to
link to once the parent is scrolled up above you.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/clear starts a new claude session and the list showed it as an unrelated
conversation. claude records no parent link anywhere — not in compactMetadata,
logicalParentUuid, summary.leafUuid, the per-session slug, or the live process
registry — so infer it: a cleared transcript opens with /clear, and /clear
happens inside one process, so the parent is the conversation in the same group
that was writing to disk at the instant this one began (4ms apart, measured).
matching is on per-minute activity rather than updatedAt, because resuming a
parent moves its end time past its child's birth and lost the link entirely for
two of the three cleared sessions here. the window is symmetric because clearing
makes claude summarise the conversation it is ending, so the parent's final
record can land after the child's first. ambiguity fails closed — the wrong
parent also renames the conversation.
read-only: nothing is written back to claude's store, and an explicit title
always wins.
also: cleared sessions were titled "<command-name>/clear</command-name>" because
claude does not set isMeta on slash commands; and the chat header was hardcoded
to undefined, so it read "New chat" above every conversation you opened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the chat group moves from ?cwd= to a path suffix behind a g/ discriminator
(/chat/g/home/me/project), and a session url carries no group at all.
the real fix is not the spelling. a session's working directory was read back
off the query string to decide where the agent executes, so the address bar was
the authority on where code runs. a pasted or refreshed /chat/<id> arrives with
no ?cwd= at all, so a turn sent before the resolve landed ran in the default
general_chat_sessions dir instead of the project; and a hand-edited ?cwd= could
name a group the session doesn't belong to, with nothing to reconcile them.
loadClaudeSessionById already resolves a session's cwd from the id alone, so the
id is the only source of truth there. it now travels on SelectedSession.cwd,
which is what the composer reads. the url can no longer contradict it.
the vocabulary lives in apps/ChatHistory/chat-routes.ts so a link built in a
panel and one built in a screen cannot drift.
also: startAgentRun no longer returns a literal chatUrl — it returns cwd and
AgentRunnerModal builds the link, so the server holds no copy of the frontend
url shape. and the post-turn permalink strips a stale ?cwd= instead of carrying
it forward onto the new session's url.
walkthrough doc gains item 13.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Replicates what Gitea's own web UI offers, on top of the sidecar's /_api pass-through.
Repository browsing (tree, file view with the FileViewer's shiki renderer, README), commits,
branches, tags, releases, issues and pull requests both per-repo and cross-repo, notifications,
explore/search and organizations. Routes are /gitea/:section plus /gitea/repo/:owner/:name/:tab/:item,
all real Links with the URL as the source of truth — no selection channel.
Markdown is rendered client-side (react-markdown + remark-gfm + rehype-sanitize, rehype-raw
deliberately absent) rather than through the instance's /api/v1/markdown, because consuming that
means dangerouslySetInnerHTML and there is no DOMPurify in the tree with installs frozen. The cost
is Gitea's #123 and @mention cross-references; relative links and images are resolved instead.
The /markdown and /markup allow-list entries stay, so that door is open when a sanitiser lands.
retargetUrls rebases instance-minted URLs onto a browser-reachable origin, IN ONE DIRECTION ONLY.
This instance answers with two: /user and /repos build from its configured ROOT_URL
(http://localhost:9004), /contents from the public host. An unconditional rewrite onto the
connection URL therefore broke the second set to match the first, turning working https links into
dead loopback ones. Only a private/loopback URL is rewritten now, and only when the target is
itself public; when the connection URL is a dial address nothing is touched and the connection
screen says why avatars will not load.
Also carries the frontend half of the one-instance-many-tokens model: the connection form draws a
URL field only for the owner and sends no url key at all for anyone else, ServiceConnection.url is
string | null to match officerdb, and the rebase origin comes from the resolved instanceUrl rather
than connection.url, which is null for a member.
Not verified: no runtime pass since the last four changes, the issues and pull views have never
rendered a row (the instance has none), and the member path has never executed (one account).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the config cache is keyed by user, but a member's entry holds the owner's
base — resolved at read time, since a member's row stores no url. dropping
only the caller's entry was correct while each row was self-contained; once
rows inherit, the owner moving or disconnecting the instance left every
member cached against the previous host until the sidecar restarted.
also returns the resolved instanceUrl from connectionState. members not
being able to set it is the invariant; not seeing it never was and could
not be — every avatar_url the instance hands back is on that origin.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gitea is the first service where the server is shared but the account is not.
The owner connects the instance; every other user supplies only their own access
token and sees their own repositories, notifications and issues.
The plumbing was already per-user — createSidecarProxy injects the authenticated
caller's id as X-Officer-User, the sidecar refuses a request without it, and
service_connections is UNIQUE (user_id, service). What was wrong is that url and
token lived in the same row and a save demanded both, so a member would have had
to type the instance URL. That is worse than inconvenient: a member who can name
the URL has a per-user SSRF hop behind a settings form, and the sidecar would
dutifully attach their token to it.
`url` is now nullable, and NULL means "inherit the instance". The owner's row
carries the URL and IS the instance; everyone else's row is a credential. A
member's URL is therefore not stored rather than merely hidden — which is what
makes "members never see the instance URL" a property of the schema instead of a
filter somebody has to remember on every response.
Resolution lives in one place (getResolvedServiceCredentials / getServiceInstanceUrl)
rather than in each sidecar, so there is a single answer to "where is this
service" and no sidecar can accidentally trust a member-supplied URL.
Rules, all enforced in the sidecar rather than the UI, because a form that hides
a field is a suggestion and these are rules:
owner PUT /_config { url, token }, as before
member PUT /_config { token } only; a url in the body is REJECTED, not
ignored — ignoring it would leave someone debugging a
screen quietly talking to a different server
member, no instance 409, "the server owner has not connected a Gitea
instance yet"
member GET /_config has no url to return
owner disconnects members keep their tokens and resolve to nothing; no
instance, no service
GET /_config also now answers `instanceConfigured` and `isOwner`, which is what
lets the UI tell "you have not connected yet" from "there is nothing here to
connect to" — different screens.
memos, slskd and transmission front a single daemon and always store their own
URL; they now treat a null as a malformed row rather than reaching for somebody
else's instance.
Verified on a scratch database: pushing twice adds no diff churn beyond the known
pk_music_now_playing pair, and the resolution behaves — member GET returns a null
url, member credentials resolve to the owner's base with the member's own token,
and deleting the owner's row leaves the member's token intact but resolving to
nothing. All four live rows have a url today, so the column change applies
without touching data.
Not reachable by a real member yet: the account backstop still confines
non-owners to /api/auth + /api/music. This works the moment the capability model
lands, and until then is testable only by minting a token.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The permission model being built reads the HTTP method to decide whether a
non-owner may make a call: safe methods are reads, everything else is a write.
That only works if the method tells the truth. These five read something and
returned it while announcing themselves as writes, so a member would have been
denied a read they are entitled to because of a habit in how the route was
declared.
/api/file-browser/video-info POST {url} -> GET ?url=
/api/file-browser/video-playlist POST {url} -> GET ?url=
/api/server-settings/ocr/models POST {url} -> GET ?url=
/api/transmission/_officer/port-test POST -> GET
/api/jellyfin/_config/:id/test POST|GET -> GET only
The last one already answered to both, which is worse than either: a method that
means nothing cannot be the thing authorisation reads.
Deliberately stops at five. A sweep of all 100 mutating routes found many more
reads wearing POST, and they are staying, for two reasons that are not going
away: some need a request body GET cannot carry (/stt takes multipart audio;
/tts, /ocr, /transcribe take payloads), and some carry a credential, where a
query string is the wrong place — access logs, shell history and Referer headers
all capture those, request bodies do not (/tts/voices takes an apiKey, the four
/test endpoints take connection secrets, /local-providers/probe takes auth).
So the method alone can never carry the permission model, and the registry will
need an explicit per-route classification regardless. Converting these five is
worth it because it is free; converting the rest would be a breaking change
across 117 mobile call sites that buys nothing.
Web callers updated in the same commit; the sidecar contract comments now match.
Mobile has exactly one caller to change — transmissionPortTest in
packages/core/src/services/transmission.ts — and no shim was added, because an
endpoint answering to both methods is the problem this commit exists to fix.
docs/api-method-changes-2026-08-06.md is the handoff for the mobile team: what
changed, the one line to edit, what deliberately did NOT change and why, and how
to verify.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A Member account could open a terminal. Demonstrated, not inferred: on 2026-08-06
a token for dingoshf@gmail.com (role Member) was refused GET /api/tasks with 403
and, in the same minute, opened /api/tasks/pipeline/ws with 101 Switching
Protocols.
The account backstop lives in originScopeMiddleware, which is a Hono middleware.
Websocket upgrades never reach Hono: Bun's route table in server.tsx matches
'/api/terminal/ws' and its siblings before the '/api/*' entry that hands off, so
NON_OWNER_PATHS was enforced on HTTP and nowhere else. upgradeWs verified the
token and the blacklist, then upgraded — it proved WHO was calling and never
asked what they could reach. None of the handlers behind it checked either;
terminal, chat and desktop have no authorisation code at all.
What was reachable with any valid token: a shell as the owner in the owner's
home, the agent with --dangerously-skip-permissions, arbitrary script execution
through task-runner and pipeline, and the owner's physical screen and keyboard
over the VNC mirror.
upgradeWs now refuses any provider a non-owner has no business on. cliamp and
cliamp-audio stay open to them — those are the socket half of /api/music, which
is what a music account exists for. An unrecognised provider denies.
NON_OWNER_WS_PROVIDERS is declared beside NON_OWNER_PATHS on purpose. They are
one rule at two doors, and the whole failure was that only one door had it;
splitting them across files is how that happens again.
The vault socket was already gated — it verifies isSuperAdmin in `open` and
closes 4001 — so upgradeWs was the only gap.
Not yet verified against the running server: platform TS does not hot-reload, so
the 101 above still reproduces until `pm2 restart officer`. The provider table
was checked in isolation: the five execution providers and an unknown name all
deny, cliamp and cliamp-audio allow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The policy editor now opens read-only behind an Edit button, and edit mode brings up an assistant:
describe the change, get a complete revised document back, read a line diff of it, and only then press
Save. Nobody writes an ACL from memory — it is HuJSON in Tailscale's grammar keyed to names only the
server knows — and that, not typing speed, is what made this screen unusable.
The model never touches Headscale. It proposes text, the text lands in the editor's draft, and the
existing Save button is still the only thing that leaves the browser. A model that could write the ACL
directly is one that can partition the network the owner is connected through.
The sidecar calls officer-anthropic-proxy on loopback for one request with a timeout — no session, no
agent, nothing persisted. It sends the draft on screen plus the tailnet's vocabulary (user, node and tag
names) and no credentials of any kind. It does not validate the reply either: Headscale owns the only
parser that counts, same argument as policy.ts.
policy: /_officer/policy GET/PUT. the text goes up byte for byte and
headscale's verdict comes back verbatim — it owns the only parser that
resolves groups, tags and hosts, so a second one here would disagree with
what actually enforces. a file-backed policy is still served over GET and
only refuses on PUT, so writability cannot be read: the first save finds
out, and a refusal becomes a persistent read-only banner rather than a
rejection the owner would go hunting for a syntax error over.
nodes: move between users, and a tag editor for the setTags route that had
no ui. both sit together in the expanded card because both decide which
policy rules apply to a node, and a move says so before it happens.
Reads the per-server Officer Companion (COMMS/HEADSCALE_COMPANION_API.md): container health with its
own diagnosis, a log snapshot, a live SSE tail, and restart/stop/start.
The companion sits at ${server.url}/officer-api behind the same admin key we already store, so there
is nothing new to register — but only the sidecar can decrypt that key, so the sidecar proxies it and
the browser never talks to the companion directly. That also rules out EventSource for the stream
(no Authorization header), which is why the tail is fetch() + a hand-rolled SSE reader.
Two inversions the code is built around: /health is always HTTP 200 and must be read by verdict, and
an absent companion is a state to render rather than an error — an HTML 502 is nginx, a JSON 502 is
the companion reporting a failed docker op, and the admin API on the same domain is independent
either way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every diagnostic in this app goes through headscale's API, which is exactly the
channel that is gone when you most need it — headscale crashed, the tailnet is
down, the logs are the only evidence. This adds the escape hatch: a per-server
SSH address and a Console section that opens a shell on that machine.
Deliberately thin. `headscale_servers.ssh_host` stores where to point ssh and
nothing else: no password, no key, no port. The console runs plain `ssh <host>`
in the same pty every other terminal panel uses, authenticating with whatever
~/.ssh on this box already knows. There is no credential here to protect and
this file must never grow one.
The address is NOT derived from the control-server URL and the form warns when
you type the same host into both — a console that resolves through the name
headscale serves goes down with it, which is the one thing it exists to survive.
It is also not validated on save, for the same reason: refusing to store the
escape hatch because the machine is unreachable is precisely backwards. Reaching
it is a separate, explicit Test connection button (BatchMode=yes, so a key that
needs a passphrase fails visibly instead of hanging on a prompt).
The host is validated to a conservative charset rather than quoted, because it
is typed into an interactive shell — rejecting `1.2.3.4; rm -rf /` while the
form is still open beats letting it survive to the shell as someone else's
problem. A jump host or an odd port belongs in ~/.ssh/config as a Host alias,
which the field accepts by name.
Also fixes a latent bug this would have hit immediately: TerminalView's
`initialInput` guard is scoped to a mount, so a remount typed the command again
into a live shell. A `replay` frame proves the session already ran it, so treat
it as sent. Harmless for `ls`; for the console it meant an ssh nested inside the
ssh you were already in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the gap limit was 1000, hardcoded, with the comment noting that raising it costs
node CPU and not correctness — and no way to raise it. it is the one thing about
a scan only the owner can know: how many addresses their old wallet handed out
and never had paid. RescanOptions threads from the POST body through the backend
and the source to utxos/scan, capped at 100k because past that the scan takes
longer than anyone waits. the card gets a "search depth" field beside the button,
blank meaning the default.
deleting a wallet with no seed took one unconfirmed request. the dialog asked for
the wallet's name and then threw the answer away, so the check existed only for
whoever went through the dialog — a node wallet still holds the credential, the
labels and the freezes. confirmName now travels with the request and the route
enforces it. a bodyless DELETE is told which field is missing rather than that
its JSON did not parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
four wallet defects, none urgent, all cheap:
selectCoins let an explicit coin-control pick override a freeze. naming an
outpoint now overrides only the confirmed-only default; frozen is absolute.
balances counted frozen coins in onchainConfirmed, so Send showed a figure a
max-value spend could not reach. Balances gains onchainFrozen — a component of
confirmed, not a deduction — filled at the route layer, because freezing is
Officer policy in Postgres and no backend can see it. The route only reads utxos
when something is actually frozen. Send subtracts it under "Spendable";
Overview lists it beside unconfirmed.
a rescan in flight upstream was invisible after a sidecar restart, and a second
POST would have queued behind it (scantxoutset is single-threaded node-wide).
adoptRescan polls an existing NBXplorer scan instead of starting one, and the
GET route falls back to it when local state is gone.
the per-variant scan deadline counted queue time, so a variant that sat behind
another wallet's scan timed out without ever having run. the deadline now
refreshes while the status reads Queued.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
two holes found auditing the wallet sidecar after the frozen-utxo fix.
the brute-force backoff lived inside UnlockSession.unlock alone, so /unlock capped
at five guesses a minute while export-seed — the one endpoint that returns the words
in the clear — took unlimited ones. every passphrase check now goes through the same
guard. verifyPassphrase rethrows LOCKED_OUT rather than folding it into `false`, so a
caller can tell "wrong" from "stop".
nextUnused advanced its mark on every issuance, paid or not, so a run of unpaid
addresses walked it past the end of the window the next scan covers; a payment there
would never be found again, and esplora has no rescan to go looking. sources now
declare how far past a scan's last index they can still see, and issuance clamps to
it — re-offering a virgin address rather than handing out one that could lose money.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>