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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user