Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ebc4d0ccd | ||
|
|
1292a5c5ab |
@@ -80,6 +80,16 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
lookup, the role cache and the fail-closed catches is exercised only by hand. It is the file
|
||||
standing between a Member and a shell.
|
||||
|
||||
- [ ] **`assertCapabilityTotality` checks the wrong list, and `registry.test.ts` has been red since
|
||||
2026-08-13.** It is fed `Object.keys(handlers)` from `server.tsx`, but Bun serves the *route table*.
|
||||
Those diverged when the cliamp/desktop/vault plugins were switched off: `/api/cliamp/ws` and
|
||||
`/api/cliamp/audio/ws` are still live routes with their handlers and registry claims commented out.
|
||||
Not exploitable — `isWsProviderAllowed` finds no capability and 403s a member; the owner upgrades onto
|
||||
a dead socket. But the boot check that exists to stop exactly this cannot see it. Two fixes: point
|
||||
totality at the route table, and either delete the dead routes or restore their claims. The 8 failing
|
||||
tests in `registry.test.ts` are the same drift — `REAL_WS` still lists all nine providers as served,
|
||||
which is why nobody noticed. Found 2026-08-14.
|
||||
|
||||
- [ ] **No empty state for a denied screen.** A member who reaches a route their role lacks gets a
|
||||
broken panel or an endless spinner rather than a clean refusal.
|
||||
|
||||
|
||||
@@ -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