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
|
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.
|
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
|
- [ ] **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.
|
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 { basename, dirname, join } from 'node:path';
|
||||||
import type { TurnMessage } from '../chat/types';
|
import type { TurnMessage } from '../chat/types';
|
||||||
import { sendClaudeCodeStreaming } from '../../channels/send-claude-code';
|
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 { getAgentRunsDir, getOwnerHomeDir } from '../../data-path';
|
||||||
import { getAgentByDirName, DEFAULT_AGENT_MODEL, type AgentRecord } from './agent-files';
|
import { getAgentByDirName, DEFAULT_AGENT_MODEL, type AgentRecord } from './agent-files';
|
||||||
import { logger } from '../chat/logger';
|
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.
|
* which is fine: while a run is live you find it as the newest entry in the agent's project group.
|
||||||
*/
|
*/
|
||||||
function titleRun(
|
function titleRun(
|
||||||
who: { email: string; home: string; osUser: string | null },
|
who: ChatIdentity,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
claudeSessionId: string,
|
claudeSessionId: string,
|
||||||
agentName: string,
|
agentName: string,
|
||||||
@@ -172,10 +172,11 @@ export async function startAgentRun(params: StartAgentRunParams): Promise<StartA
|
|||||||
if (msg.claudeSessionId) {
|
if (msg.claudeSessionId) {
|
||||||
run.claudeSessionId = msg.claudeSessionId;
|
run.claudeSessionId = msg.claudeSessionId;
|
||||||
titleRun(
|
titleRun(
|
||||||
// `osUser: null` tracks `homeDir` above: it is `getOwnerHomeDir`, which discards the email it is
|
// `osUser: null` and `isOwner: true` both track `homeDir` above: it is `getOwnerHomeDir`, which
|
||||||
// given, so an agent run is always the owner's — its transcript is theirs and readable directly.
|
// 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.
|
// 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,
|
cwd,
|
||||||
msg.claudeSessionId,
|
msg.claudeSessionId,
|
||||||
agent.name || agent.dirName,
|
agent.name || agent.dirName,
|
||||||
|
|||||||
@@ -37,14 +37,31 @@ import { readSttConfig } from '../server-settings/stt';
|
|||||||
* `resolveTurnIdentity` refuses: there is no
|
* `resolveTurnIdentity` refuses: there is no
|
||||||
* safe home to substitute, and the owner's is the one wrong answer.
|
* 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
|
* Reachable by a member since 2026-08-12 — see the note below on what replaced the wholesale refusal that
|
||||||
* before it is opened, not a live fix.
|
* used to stand at the top of this router.
|
||||||
*/
|
*/
|
||||||
async function chatIdentity(user: { id: number; email: string }): Promise<ChatIdentity> {
|
async function chatIdentity(user: { id: number; email: string }): Promise<ChatIdentity> {
|
||||||
const resolved = await resolveHomeDir(user.id);
|
const resolved = await resolveHomeDir(user.id);
|
||||||
if (!resolved.ok) throw errors.FORBIDDEN(resolved.reason);
|
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 { transcribeAudio } from '../stt/transcribe';
|
||||||
import { registerAgentPanelRoutes } from './agent-panels-routes';
|
import { registerAgentPanelRoutes } from './agent-panels-routes';
|
||||||
|
|
||||||
@@ -88,7 +105,9 @@ chatRouter.get('/sessions', async (ctx) => {
|
|||||||
const who = await chatIdentity(ctx.get('user'));
|
const who = await chatIdentity(ctx.get('user'));
|
||||||
const cwd = cwdOf(ctx, who.home);
|
const cwd = cwdOf(ctx, who.home);
|
||||||
const claude = listClaudeSessions(who, cwd).map((s) => ({ ...s, harness: 'claude' as const }));
|
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));
|
const sessions = [...claude, ...opencode].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||||
return ctx.json({ sessions });
|
return ctx.json({ sessions });
|
||||||
});
|
});
|
||||||
@@ -105,6 +124,9 @@ chatRouter.get('/sessions/:id', async (ctx) => {
|
|||||||
const cwd = cwdOf(ctx, who.home);
|
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
|
// 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.
|
// 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)
|
const detail = isOpenCodeSessionId(id)
|
||||||
? await loadOpenCodeSession(id)
|
? await loadOpenCodeSession(id)
|
||||||
: (loadClaudeSession(who, cwd, id) ?? loadClaudeSessionById(who, id));
|
: (loadClaudeSession(who, cwd, id) ?? loadClaudeSessionById(who, id));
|
||||||
@@ -151,9 +173,11 @@ chatRouter.get('/live', async (ctx) => {
|
|||||||
const email = who.email;
|
const email = who.email;
|
||||||
// Both harnesses, asked in parallel. Either failing contributes nothing rather than failing the panel:
|
// Both harnesses, asked in parallel. Either failing contributes nothing rather than failing the panel:
|
||||||
// both registry calls swallow their errors and return [].
|
// 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([
|
const [live, liveOpenCode] = await Promise.all([
|
||||||
sidecar.listLiveClaudeSessions(user.id),
|
sidecar.listLiveClaudeSessions(user.id),
|
||||||
sidecar.listLiveOpenCodeSessions(),
|
who.isOwner ? sidecar.listLiveOpenCodeSessions() : Promise.resolve([]),
|
||||||
]);
|
]);
|
||||||
const sessions = live.map((session) => {
|
const sessions = live.map((session) => {
|
||||||
// Resolve by Claude's id, never by the session key — the key is officer's handle and the transcript
|
// 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 who = await chatIdentity(ctx.get('user'));
|
||||||
const id = ctx.req.param('id');
|
const id = ctx.req.param('id');
|
||||||
const cwd = cwdOf(ctx, who.home);
|
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);
|
const ok = isOpenCodeSessionId(id) ? await deleteOpenCodeSession(id) : deleteClaudeSession(who, cwd, id);
|
||||||
if (!ok) return ctx.text('Not found', 404);
|
if (!ok) return ctx.text('Not found', 404);
|
||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
@@ -225,6 +250,7 @@ chatRouter.patch('/sessions/:id/title', async (ctx) => {
|
|||||||
const cwd = cwdOf(ctx, who.home);
|
const cwd = cwdOf(ctx, who.home);
|
||||||
const { title } = await ctx.req.json<{ title?: string }>();
|
const { title } = await ctx.req.json<{ title?: string }>();
|
||||||
if (!title?.trim()) return ctx.text('title is required', 400);
|
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)
|
const ok = isOpenCodeSessionId(id)
|
||||||
? await renameOpenCodeSession(id, title.trim())
|
? await renameOpenCodeSession(id, title.trim())
|
||||||
: renameClaudeSession(who, cwd, 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).
|
// GET /chat/models — Claude tiers only (the runner is the `claude` CLI).
|
||||||
chatRouter.get('/models', async (ctx: Context) => {
|
chatRouter.get('/models', async (ctx: Context) => {
|
||||||
|
const who = await chatIdentity(ctx.get('user'));
|
||||||
try {
|
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' };
|
const providerNames: Record<string, string> = { 'claude-code': 'Claude Code', opencode: 'OpenCode Zen' };
|
||||||
return ctx.json({ models, providerNames, hostHome: process.env.HOME ?? '' });
|
return ctx.json({ models, providerNames, hostHome: process.env.HOME ?? '' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ export type ChatIdentity = {
|
|||||||
* is the whole reason a member's chat list was empty while their chat worked.
|
* is the whole reason a member's chat list was empty while their chat worked.
|
||||||
*/
|
*/
|
||||||
osUser: string | null;
|
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');
|
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 });
|
logger.info('Model selected for chat', { sessionId, model, clientModel: msg.model || null });
|
||||||
|
|
||||||
// Route by harness: claude-code → Claude sidecar; anything else → OpenCode server.
|
// Route by harness: claude-code → Claude sidecar; anything else → OpenCode server.
|
||||||
return isClaudeModel(model)
|
if (isClaudeModel(model)) return handleClaudeCodeChat(ws, sessionId, model, msg, prompt);
|
||||||
? handleClaudeCodeChat(ws, sessionId, model, msg, prompt)
|
|
||||||
: handleOpenCodeChat(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(
|
async function handleClaudeCodeChat(
|
||||||
|
|||||||
Reference in New Issue
Block a user