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
+6 -5
View File
@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
import { basename, dirname, join } from 'node:path';
import type { TurnMessage } from '../chat/types';
import { sendClaudeCodeStreaming } from '../../channels/send-claude-code';
import { renameClaudeSession } from '../chat/claude-sessions';
import { renameClaudeSession, type ChatIdentity } from '../chat/claude-sessions';
import { getAgentRunsDir, getOwnerHomeDir } from '../../data-path';
import { getAgentByDirName, DEFAULT_AGENT_MODEL, type AgentRecord } from './agent-files';
import { logger } from '../chat/logger';
@@ -87,7 +87,7 @@ export function buildAgentPrompt(agent: AgentRecord, inputs: Record<string, unkn
* which is fine: while a run is live you find it as the newest entry in the agent's project group.
*/
function titleRun(
who: { email: string; home: string; osUser: string | null },
who: ChatIdentity,
cwd: string,
claudeSessionId: string,
agentName: string,
@@ -172,10 +172,11 @@ export async function startAgentRun(params: StartAgentRunParams): Promise<StartA
if (msg.claudeSessionId) {
run.claudeSessionId = msg.claudeSessionId;
titleRun(
// `osUser: null` tracks `homeDir` above: it is `getOwnerHomeDir`, which discards the email it is
// given, so an agent run is always the owner's — its transcript is theirs and readable directly.
// `osUser: null` and `isOwner: true` both track `homeDir` above: it is `getOwnerHomeDir`, which
// discards the email it is given, so an agent run is always the owner's — its transcript is theirs
// and readable directly. `agents` is an `execution` capability, so no other account reaches this.
// If agent runs ever reach members, this and line 134 have to move together.
{ email: params.user.email, home: homeDir, osUser: null },
{ email: params.user.email, home: homeDir, osUser: null, isOwner: true },
cwd,
msg.claudeSessionId,
agent.name || agent.dirName,