Files
boocode/apps/server/src/routes/sessions.ts
indifferentketchup ad45b28250 v1.13.19-html-artifact-panes: pane-based artifact viewer with on-request HTML
Every assistant message gets an "Open in pane" affordance that opens the
message in the workspace splitter — Markdown pane (Copy + Download .md) by
default; HTML pane (Download .html only) when the model emits a self-contained
<!DOCTYPE html> or fenced ```html artifact. BOOCHAT.md rule keeps Markdown
default at every length; HTML opt-in on explicit user request.

Backend: services/artifacts.ts (slug derivation + write helpers with
symlink-escape guard via realpath-after-mkdir), routes/artifacts.ts (POST
download + GET stream with nosniff + CSP sandbox defense-in-depth), HTML
detection in finalizeCompletion writing a new message_parts.kind='html_artifact'
row (schema CHECK extended via v1.13.13 pattern), graceful 1MB cap via the
pure decideHtmlArtifactWrite helper. PartKind union extended.

Frontend: MarkdownRenderer.tsx extracted from MessageBubble's inline
MarkdownBody for reuse; MarkdownArtifactPane.tsx + HtmlArtifactPane.tsx with
loading/error states; pane state is reference-only ({chat_id, message_id,
title}) — content fetched on mount to keep workspace_panes jsonb small and
avoid 1MB blobs riding session_workspace_updated frames. iframe sandbox
locked to allow-scripts allow-clipboard-write allow-downloads with no
allow-same-origin, srcDoc not src. openInPane discriminates 404 (expected
fallback) from real errors (toast + bail). PanelRightOpen icon button with
mobile 44px tap-target.

31 new server unit tests including a real-symlink filesystem case; 332/332
server tests passing, tsc clean both sides, pnpm -C apps/web build green.
Smoke deferred to first deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:43:13 +00:00

443 lines
17 KiB
TypeScript

import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import type { Config } from '../config.js';
import type { Broker } from '../services/broker.js';
import type { Session } from '../types/api.js';
import { getSetting } from './settings.js';
const CreateBody = z.object({
name: z.string().min(1).max(200).optional(),
model: z.string().min(1).max(200).optional(),
system_prompt: z.string().max(8000).optional(),
agent_id: z.string().min(1).max(200).nullable().optional(),
});
// v1.14.x-html-artifact-panes: 'markdown_artifact' + 'html_artifact' added
// as pane kinds. Pane state is a reference only (chat_id + message_id +
// title) — the actual artifact body is fetched from the message row or
// message_parts.payload by the pane component on mount.
const MarkdownArtifactStateZ = z.object({
chat_id: z.string().min(1).max(200),
message_id: z.string().min(1).max(200),
title: z.string().max(500),
});
const HtmlArtifactStateZ = z.object({
chat_id: z.string().min(1).max(200),
message_id: z.string().min(1).max(200),
title: z.string().max(500),
});
const WorkspacePaneZ = z.object({
id: z.string().min(1).max(200),
kind: z.enum([
'chat',
'terminal',
'agent',
'empty',
'settings',
'markdown_artifact',
'html_artifact',
]),
chatId: z.string().min(1).max(200).optional(),
chatIds: z.array(z.string().min(1).max(200)).max(50),
activeChatIdx: z.number().int(),
markdown_artifact_state: MarkdownArtifactStateZ.optional(),
html_artifact_state: HtmlArtifactStateZ.optional(),
});
const WorkspacePanesBody = z.object({
workspace_panes: z.array(WorkspacePaneZ).max(10),
});
const PatchBody = z.object({
name: z.string().min(1).max(200).optional(),
model: z.string().min(1).max(200).optional(),
system_prompt: z.string().max(8000).optional(),
agent_id: z.string().min(1).max(200).nullable().optional(),
// v1.9: null = inherit from project default; true/false = explicit override.
web_search_enabled: z.boolean().nullable().optional(),
// v1.13.17-cross-repo-reads: revocation pathway. PATCH with a shortened
// list deletes entries; the grant flow itself APPENDS via the separate
// grant_read_access endpoint, never via this PATCH. Frontend treats this
// as "send the new whole array". Per-entry shape validation: must be
// absolute, no NUL, no `/..` traversal segment. Server doesn't re-validate
// whitelist membership on PATCH — entries already in the array were
// placed there by the grant endpoint after a full whitelist+repo-shape
// check. THE SUBSET CHECK (every entry must already be in the current
// array) is enforced at runtime in the PATCH handler below, NOT in this
// zod refinement, because the refinement has no access to the existing
// session row.
allowed_read_paths: z
.array(
z
.string()
.min(1)
.max(1024)
.refine((p) => p.startsWith('/') && !p.includes('\0') && !p.includes('/..'), {
message: 'must be an absolute path without traversal markers',
}),
)
.max(64)
.optional(),
});
async function resolveDefaultModel(sql: Sql, config: Config): Promise<string> {
const fromDb = await getSetting<string>(sql, 'default_model');
if (typeof fromDb === 'string' && fromDb.length > 0) return fromDb;
return config.DEFAULT_MODEL;
}
// v1.13.17-cross-repo-reads: subset enforcement for PATCH allowed_read_paths.
// The PATCH route can only SHRINK the array; growth happens exclusively via
// POST /api/chats/:id/grant_read_access (which requires user consent).
// Returns the list of disallowed-additions; an empty list means the request
// is a valid shrink-or-no-op. Exported for the unit test.
export function findUnauthorizedAdditions(
prior: readonly string[],
requested: readonly string[],
): string[] {
const priorSet = new Set(prior);
return requested.filter((p) => !priorSet.has(p));
}
export function registerSessionRoutes(
app: FastifyInstance,
sql: Sql,
config: Config,
broker: Broker
): void {
app.get<{ Params: { id: string }; Querystring: { status?: string } }>(
'/api/projects/:id/sessions',
async (req, reply) => {
const project = await sql`SELECT id FROM projects WHERE id = ${req.params.id}`;
if (project.length === 0) {
reply.code(404);
return { error: 'project not found' };
}
const status = req.query.status === 'archived' ? 'archived' : 'open';
const rows = await sql<Session[]>`
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes, allowed_read_paths
FROM sessions
WHERE project_id = ${req.params.id} AND status = ${status}
ORDER BY updated_at DESC
`;
return rows;
}
);
app.post<{ Params: { id: string } }>(
'/api/projects/:id/sessions',
async (req, reply) => {
const parsed = CreateBody.safeParse(req.body ?? {});
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const project = await sql<{ id: string }[]>`
SELECT id FROM projects WHERE id = ${req.params.id}
`;
if (project.length === 0) {
reply.code(404);
return { error: 'project not found' };
}
let model = parsed.data.model;
if (!model) {
const lastUsed = await sql<{ model: string }[]>`
SELECT model FROM sessions
WHERE project_id = ${req.params.id}
ORDER BY created_at DESC
LIMIT 1
`;
model = lastUsed[0]?.model ?? (await resolveDefaultModel(sql, config));
}
const name = parsed.data.name ?? 'New session';
const systemPrompt = parsed.data.system_prompt ?? '';
// v1.11.5.2: default is null (no agent / raw chat) when the client
// omits agent_id. Sam can still pick one from the AgentPicker after
// the session loads. Was: first agent in the project's effective list
// (alphabetically — usually "Code Reviewer"), which felt presumptuous.
const agentId = parsed.data.agent_id ?? null;
const row = await sql.begin(async (tx) => {
const [session] = await tx<Session[]>`
INSERT INTO sessions (project_id, name, model, system_prompt, agent_id)
VALUES (${req.params.id}, ${name}, ${model}, ${systemPrompt}, ${agentId})
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes
`;
await tx`
INSERT INTO chats (session_id, name, status)
VALUES (${session!.id}, NULL, 'open')
`;
return session!;
});
broker.publishUserFrame('default', {
type: 'session_created',
session: row,
project_id: row.project_id,
});
reply.code(201);
return row;
}
);
app.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => {
const rows = await sql<Session[]>`
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes, allowed_read_paths
FROM sessions WHERE id = ${req.params.id}
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
return rows[0];
});
app.patch<{ Params: { id: string } }>(
'/api/sessions/:id',
async (req, reply) => {
const parsed = PatchBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const { name, model, system_prompt } = parsed.data;
// agent_id and web_search_enabled are both tri-state on the wire: omitted
// = no change, null = clear/inherit, value = set. CASE WHEN inside SET
// handles all three atomically.
const agentIdProvided = parsed.data.agent_id !== undefined;
const newAgentId = parsed.data.agent_id ?? null;
const wseProvided = parsed.data.web_search_enabled !== undefined;
const newWse = parsed.data.web_search_enabled ?? null;
// v1.13.17-cross-repo-reads: tri-state on the wire (undefined = no
// change, [] = clear). Frontend currently uses this PATCH only for
// revocation (delete a single entry from the existing array, send
// shortened result). Append-style grants go through the dedicated
// grant_read_access endpoint inside the inference loop.
const arpProvided = parsed.data.allowed_read_paths !== undefined;
const newArp = parsed.data.allowed_read_paths ?? [];
// Read the prior name + grants so the post-update publish can skip no-op
// renames (PATCH { name: "Foo" } where the session is already "Foo") AND
// so the subset check below has the current grant list to compare against.
// The window between SELECT and UPDATE is sub-millisecond in the same
// request handler; a concurrent rename in that gap would just mean one
// stale publish, which existing clients dedup by id.
const before = await sql<{ name: string; allowed_read_paths: string[] }[]>`
SELECT name, allowed_read_paths FROM sessions WHERE id = ${req.params.id}
`;
const priorName = before[0]?.name;
const priorArp = before[0]?.allowed_read_paths ?? [];
// v1.13.17-cross-repo-reads: subset enforcement. The grant flow is the
// ONLY path that can add entries to allowed_read_paths — PATCH can only
// shrink the array, never grow it. Without this guard, a malicious
// client could POST {"allowed_read_paths":["/etc"]} and bypass the
// user-consent prompt entirely. Sam flagged this in the v1.13.17
// compliance review (2026-05-22).
// Race note: a concurrent grant landing between this SELECT and the
// UPDATE below would briefly make a "shouldn't-have-been-valid" PATCH
// succeed (the newly-granted root sneaks in). Inverse race — a
// legitimate revoke happening alongside a concurrent grant — could
// briefly reject the revoke; the user retries. Both are acceptable
// given the single-user threat model + sub-millisecond window.
if (arpProvided) {
const extras = findUnauthorizedAdditions(priorArp, newArp);
if (extras.length > 0) {
reply.code(400);
return {
error: 'invalid body',
details: {
fieldErrors: {
allowed_read_paths: [
`entries must already be granted; cannot add via PATCH: ${extras.join(', ')}`,
],
},
},
};
}
}
const rows = await sql<Session[]>`
UPDATE sessions
SET
name = COALESCE(${name ?? null}, name),
model = COALESCE(${model ?? null}, model),
system_prompt = COALESCE(${system_prompt ?? null}, system_prompt),
agent_id = CASE WHEN ${agentIdProvided} THEN ${newAgentId} ELSE agent_id END,
web_search_enabled = CASE WHEN ${wseProvided} THEN ${newWse} ELSE web_search_enabled END,
allowed_read_paths = CASE WHEN ${arpProvided} THEN ${sql.array(newArp, 25)} ELSE allowed_read_paths END,
updated_at = clock_timestamp()
WHERE id = ${req.params.id}
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
agent_id, web_search_enabled, workspace_panes, allowed_read_paths
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
const session = rows[0]!;
if (name !== undefined && session.name !== priorName) {
broker.publishUserFrame('default', {
type: 'session_renamed',
session_id: session.id,
name: session.name,
});
}
// v1.9: any successful PATCH broadcasts session_updated so listeners
// (notably the SettingsPane open in another tab) can refetch and pick
// up the new fields. Frame stays lean (decision d) — payload is just
// ids + name + updated_at, the client refetches via api.sessions.get.
broker.publishUserFrame('default', {
type: 'session_updated',
session_id: session.id,
project_id: session.project_id,
name: session.name,
updated_at: session.updated_at,
});
return session;
}
);
app.patch<{ Params: { id: string } }>(
'/api/sessions/:id/workspace',
async (req, reply) => {
const parsed = WorkspacePanesBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const rows = await sql<Session[]>`
UPDATE sessions
SET workspace_panes = ${sql.json(parsed.data.workspace_panes as never)},
updated_at = clock_timestamp()
WHERE id = ${req.params.id}
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
agent_id, web_search_enabled, workspace_panes, allowed_read_paths
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
const session = rows[0]!;
broker.publishUserFrame('default', {
type: 'session_workspace_updated',
session_id: session.id,
workspace_panes: session.workspace_panes,
});
return session;
}
);
// v1.9: bulk-archive every open session in a project. Mirrors the
// single-archive shape (same broker frame type) so the existing useSidebar
// reducer cases handle it without changes — just N frames instead of 1.
app.post<{ Params: { id: string } }>(
'/api/projects/:id/sessions/archive-all',
async (req, reply) => {
const project = await sql`SELECT id FROM projects WHERE id = ${req.params.id}`;
if (project.length === 0) {
reply.code(404);
return { error: 'project not found' };
}
const rows = await sql<{ id: string }[]>`
UPDATE sessions
SET status = 'archived', updated_at = clock_timestamp()
WHERE project_id = ${req.params.id} AND status = 'open'
RETURNING id
`;
const ids = rows.map((r) => r.id);
for (const id of ids) {
broker.publishUserFrame('default', {
type: 'session_archived',
session_id: id,
project_id: req.params.id,
});
}
return { archived: ids.length, ids };
}
);
// v1.9: count helper for the confirm dialog. Cheap COUNT(*) — the settings
// pane calls it on click, not on render.
app.get<{ Params: { id: string } }>(
'/api/projects/:id/sessions/open-count',
async (req, reply) => {
const project = await sql`SELECT id FROM projects WHERE id = ${req.params.id}`;
if (project.length === 0) {
reply.code(404);
return { error: 'project not found' };
}
const rows = await sql<{ count: number }[]>`
SELECT COUNT(*)::int AS count
FROM sessions
WHERE project_id = ${req.params.id} AND status = 'open'
`;
return { count: rows[0]?.count ?? 0 };
}
);
app.post<{ Params: { id: string } }>(
'/api/sessions/:id/archive',
async (req, reply) => {
const rows = await sql<{ id: string; project_id: string }[]>`
UPDATE sessions SET status = 'archived', updated_at = clock_timestamp()
WHERE id = ${req.params.id} AND status = 'open'
RETURNING id, project_id
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'session not found or already archived' };
}
broker.publishUserFrame('default', {
type: 'session_archived',
session_id: rows[0]!.id,
project_id: rows[0]!.project_id,
});
reply.code(204);
return null;
}
);
app.post<{ Params: { id: string } }>(
'/api/sessions/:id/unarchive',
async (req, reply) => {
const rows = await sql<Session[]>`
UPDATE sessions SET status = 'open', updated_at = clock_timestamp()
WHERE id = ${req.params.id} AND status = 'archived'
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'session not found or not archived' };
}
const session = rows[0]!;
broker.publishUserFrame('default', {
type: 'session_created',
session: session,
project_id: session.project_id,
});
reply.code(200);
return session;
}
);
app.delete<{ Params: { id: string } }>(
'/api/sessions/:id',
async (req, reply) => {
const id = req.params.id;
const deleted = await sql<{ project_id: string }[]>`
DELETE FROM sessions WHERE id = ${id} RETURNING project_id
`;
if (deleted.length === 0) {
reply.code(404);
return { error: 'not found' };
}
const project_id = deleted[0]!.project_id;
broker.publishUserFrame('default', { type: 'session_deleted', session_id: id, project_id });
reply.code(204);
return null;
}
);
}