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', 'coder', 'agent', // legacy alias — normalized to coder on write '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 { const fromDb = await getSetting(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` 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` 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` 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` 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 workspacePanes = parsed.data.workspace_panes.map((pane) => pane.kind === 'agent' ? { ...pane, kind: 'coder' as const } : pane, ); const rows = await sql` UPDATE sessions SET workspace_panes = ${sql.json(workspacePanes 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` 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; } ); }