opencode is owner-only until it carries an identity

a turn on the opencode harness ran as the owner, in the owner's home, whoever
asked. handleOpenCodeChat resolves its cwd against getOwnerHomeDir(email), which
discards the email it is given, and the sidecar runs one shared `opencode serve`
as the service user — sendOpenCodeStreaming accepts userId/email/username and
forwards none of them. it carried a comment calling itself owner-only; nothing
enforced it.

reachable by any account with the `chat` grant, which every role holds by
default (DEFAULT_ROLE_CAPABILITIES), and isClaudeModel is a startsWith, so a
typo'd model string landed there too. the model is client-supplied and never
checked against the catalogue.

the same gap on the read side: opencode's session store has no per-user scoping
at all, so loadOpenCodeSession/delete/rename take an id and no identity, and the
list and live routes returned other people's conversations.

so: ChatIdentity carries isOwner as its own fact (not inferred from
osUser === null, which holds only while resolveHomeDir refuses a member without
one), and every opencode door in chat.ts checks it — list, load, live, delete,
rename — plus a refusal on the execution path in handleChat. /chat/models hides
opencode from non-owners as a courtesy; the socket refuses regardless.

a stopgap, not a design. the fix is to thread identity through the opencode
sidecar the way spawnClaudeAsMember does, and TODO.md has been saying so.

not fixed here, and worth knowing: a member's session list is still empty and
/chat/pwds still 500s, because readdirSync on their ~/.claude/projects is EACCES
— claude creates it at mode 700, which zeroes the ACL mask. visible in
officer-error.log right now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 19:27:54 +00:00
co-authored by Claude Opus 5
parent 4dc7cd90c2
commit 1292a5c5ab
4 changed files with 74 additions and 14 deletions
+24 -3
View File
@@ -351,9 +351,30 @@ async function handleChat(
logger.info('Model selected for chat', { sessionId, model, clientModel: msg.model || null });
// Route by harness: claude-code → Claude sidecar; anything else → OpenCode server.
return isClaudeModel(model)
? handleClaudeCodeChat(ws, sessionId, model, msg, prompt)
: handleOpenCodeChat(ws, sessionId, model, msg, prompt);
if (isClaudeModel(model)) return handleClaudeCodeChat(ws, sessionId, model, msg, prompt);
// ── OpenCode is owner-only until it carries an identity ──
//
// `handleOpenCodeChat` resolves its cwd against `getOwnerHomeDir(email)` — a function that discards the
// email it is given and always answers the owner — and the sidecar runs one shared `opencode serve` as the
// service user. So a turn here executes AS THE OWNER, IN THE OWNER'S HOME, whoever asked. It carried a
// comment describing itself as owner-only; this is the check that comment assumed existed.
//
// Reached by any account with the `chat` grant, which every role holds by default, and `isClaudeModel` is a
// `startsWith` — so a typo'd model string lands here too, not just a deliberate choice. `model` is
// client-supplied and never validated against the catalogue, so hiding these in `/chat/models` is not a
// substitute for refusing them here.
const identity = await resolveTurnIdentity(userId);
if (identity.kind !== 'owner') {
logger.warn('Refused an OpenCode turn for a non-owner', { userId, model, sessionId });
sendToClient(ws, {
type: 'error',
message: 'OpenCode is only available to the server owner. Pick a Claude model instead.',
});
return;
}
return handleOpenCodeChat(ws, sessionId, model, msg, prompt);
}
async function handleClaudeCodeChat(