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:
@@ -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,
|
||||
|
||||
@@ -37,14 +37,31 @@ import { readSttConfig } from '../server-settings/stt';
|
||||
* `resolveTurnIdentity` refuses: there is no
|
||||
* safe home to substitute, and the owner's is the one wrong answer.
|
||||
*
|
||||
* Unreachable by a member today — the router refuses non-owners above — so this is the path being made correct
|
||||
* before it is opened, not a live fix.
|
||||
* Reachable by a member since 2026-08-12 — see the note below on what replaced the wholesale refusal that
|
||||
* used to stand at the top of this router.
|
||||
*/
|
||||
async function chatIdentity(user: { id: number; email: string }): Promise<ChatIdentity> {
|
||||
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/<id> 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<string, string> = { 'claude-code': 'Claude Code', opencode: 'OpenCode Zen' };
|
||||
return ctx.json({ models, providerNames, hostHome: process.env.HOME ?? '' });
|
||||
} catch (err) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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