diff --git a/src/servers/api/agents/agent-runner.ts b/src/servers/api/agents/agent-runner.ts index d45c42db..c6348850 100644 --- a/src/servers/api/agents/agent-runner.ts +++ b/src/servers/api/agents/agent-runner.ts @@ -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 { const resolved = await resolveHomeDir(user.id); if (!resolved.ok) throw errors.FORBIDDEN(resolved.reason); - return { email: user.email, home: resolved.home, osUser: resolved.osUser }; + return { email: user.email, home: resolved.home, osUser: resolved.osUser, isOwner: resolved.isOwner }; } + +// ── OpenCode is owner-only, temporarily ── +// +// The Claude harness earned its way to members: the turn runs as their Linux account, the credential and +// transcripts are theirs, and every sidecar command refuses a session belonging to someone else. NONE of that +// is true of OpenCode. One `opencode serve` runs as the SERVICE user for everyone, `sendOpenCodeStreaming` +// accepts `userId`/`email`/`username` and forwards none of them, and its session store has no per-user +// scoping at all — `loadOpenCodeSession(id)` takes an id and no identity. +// +// Two consequences, both reachable by any account holding the `chat` grant, which every role has by default: +// a turn ran in the OWNER'S home as the owner, and any session on the box could be read, renamed or deleted +// by id. `handleOpenCodeChat` carried a comment calling itself owner-only; nothing enforced it. +// +// So this is a stopgap, not a design: `who.isOwner` applied at every door below, until OpenCode carries an +// identity the way `spawnClaudeAsMember` does. Restrict here rather than at the capability layer because +// `chat` is one capability covering both harnesses, and splitting it would strand the grants already issued. +// The matching refusal on the execution path is in `websocket.ts` → `handleChat`. import { transcribeAudio } from '../stt/transcribe'; import { registerAgentPanelRoutes } from './agent-panels-routes'; @@ -88,7 +105,9 @@ chatRouter.get('/sessions', async (ctx) => { const who = await chatIdentity(ctx.get('user')); const cwd = cwdOf(ctx, who.home); const claude = listClaudeSessions(who, cwd).map((s) => ({ ...s, harness: 'claude' as const })); - const opencode = await listOpenCodeSessions(cwd); + // OpenCode's store is shared and unscoped, so for anyone but the owner this list is other people's + // conversations. Empty rather than filtered: there is no per-user field to filter ON. + const opencode = who.isOwner ? await listOpenCodeSessions(cwd) : []; const sessions = [...claude, ...opencode].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); return ctx.json({ sessions }); }); @@ -105,6 +124,9 @@ chatRouter.get('/sessions/:id', async (ctx) => { const cwd = cwdOf(ctx, who.home); // Fall back to a by-id scan when the (default) cwd doesn't hold it — a fresh /chat/ deep-link/refresh // doesn't know the session's cwd. The returned detail carries the real cwd for the client to scope the UI. + // 404 rather than 403 on the OpenCode branch: a non-owner has no way to tell a session they may not read + // from one that does not exist, which is the honest answer when the store has no notion of whose it is. + if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404); const detail = isOpenCodeSessionId(id) ? await loadOpenCodeSession(id) : (loadClaudeSession(who, cwd, id) ?? loadClaudeSessionById(who, id)); @@ -151,9 +173,11 @@ chatRouter.get('/live', async (ctx) => { const email = who.email; // Both harnesses, asked in parallel. Either failing contributes nothing rather than failing the panel: // both registry calls swallow their errors and return []. + // The Claude call is already scoped by userId; the OpenCode one has no such argument, so it is asked only + // for the owner. A member's Live panel therefore shows their own turns and nothing else. const [live, liveOpenCode] = await Promise.all([ sidecar.listLiveClaudeSessions(user.id), - sidecar.listLiveOpenCodeSessions(), + who.isOwner ? sidecar.listLiveOpenCodeSessions() : Promise.resolve([]), ]); const sessions = live.map((session) => { // Resolve by Claude's id, never by the session key — the key is officer's handle and the transcript @@ -213,6 +237,7 @@ chatRouter.delete('/sessions/:id', async (ctx) => { const who = await chatIdentity(ctx.get('user')); const id = ctx.req.param('id'); const cwd = cwdOf(ctx, who.home); + if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404); const ok = isOpenCodeSessionId(id) ? await deleteOpenCodeSession(id) : deleteClaudeSession(who, cwd, id); if (!ok) return ctx.text('Not found', 404); return ctx.json({ ok: true }); @@ -225,6 +250,7 @@ chatRouter.patch('/sessions/:id/title', async (ctx) => { const cwd = cwdOf(ctx, who.home); const { title } = await ctx.req.json<{ title?: string }>(); if (!title?.trim()) return ctx.text('title is required', 400); + if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404); const ok = isOpenCodeSessionId(id) ? await renameOpenCodeSession(id, title.trim()) : renameClaudeSession(who, cwd, id, title.trim()); @@ -246,8 +272,12 @@ chatRouter.get('/tasks/:id', async (ctx) => { // GET /chat/models — Claude tiers only (the runner is the `claude` CLI). chatRouter.get('/models', async (ctx: Context) => { + const who = await chatIdentity(ctx.get('user')); try { - const models = await listChatModels(); + const all = await listChatModels(); + // Hiding these is a courtesy — the socket refuses them regardless — but offering a model that cannot run + // is how a member ends up reporting "chat is broken" for a choice the UI made available. + const models = who.isOwner ? all : all.filter((m) => m.provider === 'claude-code'); const providerNames: Record = { 'claude-code': 'Claude Code', opencode: 'OpenCode Zen' }; return ctx.json({ models, providerNames, hostHome: process.env.HOME ?? '' }); } catch (err) { diff --git a/src/servers/api/chat/claude-sessions.ts b/src/servers/api/chat/claude-sessions.ts index 7eeab373..a1d53a4c 100644 --- a/src/servers/api/chat/claude-sessions.ts +++ b/src/servers/api/chat/claude-sessions.ts @@ -39,6 +39,14 @@ export type ChatIdentity = { * is the whole reason a member's chat list was empty while their chat worked. */ osUser: string | null; + /** + * From `resolveHomeDir`. Carried as its own fact rather than inferred from `osUser === null` — that + * equivalence holds today only because `resolveHomeDir` refuses a member without one, so reading it as + * "is the owner" would silently become wrong the moment that refusal is relaxed. + * + * Used to gate the OpenCode harness, which is owner-only until it carries an identity. See `chat.ts`. + */ + isOwner: boolean; }; const claudeProjectsDir = (home: string): string => join(home, '.claude', 'projects'); diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index ae3041cb..b8dee245 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -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(