Merge branch 'rel1411/html-md': render HTML files as pages (#547) and open .md from the terminal in the rendered viewer

Conflicts resolved against #604, #606 and #601:
- constants.js: the rooted link pattern takes html-md's `markdown` and
  `html?` with #606's m4v/ogv; #604's relative pattern gets the same list
  (link-provider-regex pins the two equal); #604's findFilePathLinks /
  folder helpers and html-md's rendersAsPage() are both kept, and
  window.CodemanFilePaths exports all of them.
- terminal-ui.js: #604's resolved `target` (relative, ~/ and folder links)
  now also goes through rendersAsPage(), so every link kind routes .html to
  the page preview; .md routes there through FILE_PREVIEW_EXTENSIONS.
- CLAUDE.md, api-reference, architecture-invariants: both sides' text kept.
- test/folder-grid-ui.test.ts (#604) stubs rendersAsPage() like the other
  panels-ui harnesses.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-13 18:55:02 +02:00
commit 0996f99fbb
31 files changed
+1423 -33

No files matched your search

+32
View File
@@ -23,6 +23,7 @@ import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { findUser, setPassword, touchLastLogin, verifyPassword } from '../../user-store.js';
import { webviewCapabilities } from '../../webview-capabilities.js';
import { capabilityFromHtmlViewPath, htmlViewCapabilities } from '../../html-view-capabilities.js';
import {
capabilityFromProxyPath,
capabilityFromReferer,
@@ -183,6 +184,20 @@ function hasValidWebviewCapability(req: FastifyRequest, basePath = ''): boolean
return !!fromReferer && webviewCapabilities.resolve(fromReferer) !== undefined;
}
/**
* Whether a request is a GET for a rendered HTML file (`/html-view/<cap>/...`) with
* a live capability. Same reasoning as the web-tab proxy: the page is sandboxed into
* an opaque origin, so its asset requests carry no session cookie. Read-only by
* construction (safe methods only), and the Host allowlist still applies. OPTIONS
* is the page's CORS preflight, which registerSecurityHeaders answers itself and
* never hands to a route.
*/
function hasValidHtmlViewCapability(req: FastifyRequest): boolean {
if (req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS') return false;
const cap = capabilityFromHtmlViewPath((req.url ?? '').split('?')[0]);
return !!cap && htmlViewCapabilities.resolve(cap) !== undefined;
}
/**
* A web-tab frame that navigated itself off the proxy prefix (see
* isLostWebviewFrameNavigation). It cannot authenticate: opaque origin, no cookie,
@@ -368,6 +383,11 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean, bas
done();
return;
}
// Rendered HTML file, likewise authenticated by its capability.
if (hasValidHtmlViewCapability(req)) {
done();
return;
}
// A web-tab frame that lost its prefix: hand it back to its tab, no credentials involved.
if (serveLostWebviewFrame(req, reply)) return;
@@ -507,6 +527,8 @@ function registerMultiUserAuthHook(
// ownership against the identity BOUND TO THE CAPABILITY, which is stricter
// than re-deriving it from a request that carries no credentials.
if (hasValidWebviewCapability(req, basePath)) return;
// Rendered HTML file, likewise authenticated by its capability.
if (hasValidHtmlViewCapability(req)) return;
// A web-tab frame that lost its prefix: hand it back to its tab, no credentials involved.
if (serveLostWebviewFrame(req, reply)) return;
@@ -711,6 +733,16 @@ export function registerSecurityHeaders(app: FastifyInstance, https: boolean, ba
// are not CORS-checked). Falling through lets the proxy route reply with the
// right headers.
if (req.method === 'OPTIONS' && !hasValidWebviewCapability(req, basePath)) {
// A rendered HTML page (`/html-view/<cap>/...`) is opaque-origin for the same
// reason, so a fetch() of its own `data.json` with a non-safelisted header
// preflights with `Origin: null`. Answer it here, read-only: the route serves
// GET and HEAD only, and no credentials are involved (`*` is valid for both).
if (hasValidHtmlViewCapability(req)) {
reply.header('Access-Control-Allow-Origin', '*');
reply.header('Access-Control-Allow-Methods', 'GET, HEAD');
reply.header('Access-Control-Allow-Headers', '*');
reply.header('Access-Control-Max-Age', '600');
}
reply.code(204).send();
done();
return;
+25 -3
View File
@@ -2047,7 +2047,7 @@ function computeRewriteScrollLine(input) {
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
*/
const FILE_PATH_LINK_PATTERN =
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
function absoluteFilePathPattern() {
@@ -2059,13 +2059,18 @@ function absoluteFilePathPattern() {
* points at goes to the tail/log viewer, which is the right home for a growing
* text file and the wrong one for bytes (tailing a PNG shows binary noise).
*
* Markdown is here too (owner decision 2026-10-13): an agent's `.md` is a
* document to read, so the preview renders it (its MD pill gives the source)
* instead of the log viewer streaming it as raw text. Other text (.log, .txt,
* .json, code) keeps the log viewer, which follows a file still being written.
*
* The media entries mirror VIDEO_ATTACHMENT_EXTENSIONS/AUDIO_ATTACHMENT_EXTENSIONS
* (src/attachment-registry.ts, the single source) — they diverged once and an
* in-workspace `.m4a` opened as binary noise in the log viewer while the same
* file in /tmp played fine. test/media-extension-parity.test.ts pins the sync.
*/
const FILE_PREVIEW_EXTENSIONS = new Set(
('png jpg jpeg gif webp avif bmp ico svg pdf docx pptx xlsx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
('png jpg jpeg gif webp avif bmp ico svg pdf docx pptx xlsx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus md markdown').split(' ')
);
/** Whether a path's extension is one {@link FILE_PREVIEW_EXTENSIONS} covers. */
@@ -2095,7 +2100,7 @@ function previewsInFileViewer(filePath) {
* {@link findFilePathLinks}.
*/
const RELATIVE_FILE_PATH_LINK_PATTERN =
/(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
/(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
/** The `(?:log|txt|…)` extension group, read off the rooted pattern so it cannot drift. */
const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LINK_PATTERN.source)[1];
@@ -2208,6 +2213,21 @@ function resolveLinkedFilePath(filePath, workingDir) {
return `/${parts.join('/')}`;
}
/**
* Extensions the file preview renders as a PAGE, from the sandboxed
* `/html-view/<cap>/` route (src/web/routes/html-view-routes.ts, whose
* HTML_VIEW_EXTENSIONS this mirrors; test/file-preview-html-page.test.ts pins
* the two equal). The terminal sends a click on one to the preview, never to
* the log viewer, and the preview renders it with a Page pill back to source.
*/
const HTML_PAGE_EXTENSIONS = new Set(['html', 'htm']);
/** Whether a path names a file the preview renders as a page ({@link HTML_PAGE_EXTENSIONS}). */
function rendersAsPage(filePath) {
const ext = String(filePath || '').split('.').pop().toLowerCase();
return HTML_PAGE_EXTENSIONS.has(ext);
}
/**
* The LOGICAL line a terminal row belongs to — the rows it spans, its text as one
@@ -3358,6 +3378,8 @@ if (typeof window !== 'undefined') {
resolveLinkedFilePath,
previewsInFileViewer,
FILE_PREVIEW_EXTENSIONS,
rendersAsPage,
HTML_PAGE_EXTENSIONS,
};
window.CodemanTerminalLines = { terminalLogicalLine, terminalPathLine };
window.CodemanUrlSession = { sessionIdFromFragment };
+1
View File
@@ -618,6 +618,7 @@
<button class="btn-icon-sm" id="filePreviewNextBtn" onclick="app.stepFolderPreview(1)" title="Next file in folder (→)" aria-label="Next file in folder" hidden>&rsaquo;</button>
<button class="btn-icon-sm" id="filePreviewFolderBtn" onclick="app.openFolderOfPreview()" title="Show folder" aria-label="Show folder" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
<button class="btn-icon-sm file-preview-pill" id="filePreviewMdBtn" onclick="app.toggleFilePreviewMd()" title="Rendered markdown" aria-label="Rendered markdown" aria-pressed="true" hidden>MD</button>
<button class="btn-icon-sm file-preview-pill" id="filePreviewPageBtn" onclick="app.toggleFilePreviewPage()" title="Rendered page" aria-label="Rendered page" aria-pressed="true" hidden>Page</button>
<button class="btn-icon-sm" id="filePreviewLinesBtn" onclick="app.toggleFilePreviewLines()" title="Line numbers" aria-label="Line numbers" aria-pressed="false" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg></button>
<button class="btn-icon-sm" id="filePreviewWrapBtn" onclick="app.toggleFilePreviewWrap()" title="Wrap lines" aria-label="Wrap lines" aria-pressed="true" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><path d="M3 12h15a3 3 0 1 1 0 6h-4"/><polyline points="16 16 14 18 16 20"/><line x1="3" y1="18" x2="10" y2="18"/></svg></button>
<button class="btn-icon-sm file-preview-edit-btn" id="filePreviewEditBtn" onclick="app.enterFilePreviewEdit()" title="Edit file" aria-label="Edit file" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z"/></svg></button>
+60 -3
View File
@@ -30,10 +30,11 @@ const MARKDOWN_EXTS = new Set(['md', 'markdown']);
// would be dropped on the next save).
const FILE_PREVIEW_PREF_KEYS = {
mdRendered: 'codeman:filePreviewMdRendered',
htmlRendered: 'codeman:filePreviewHtmlRendered',
lineNumbers: 'codeman:filePreviewLineNumbers',
wrap: 'codeman:filePreviewWrap',
};
const FILE_PREVIEW_PREF_DEFAULTS = { mdRendered: true, lineNumbers: false, wrap: true };
const FILE_PREVIEW_PREF_DEFAULTS = { mdRendered: true, htmlRendered: true, lineNumbers: false, wrap: true };
const AWAY_DIGEST_SECTIONS = [
['needsAttention', 'Needs Attention'],
['completed', 'Completed'],
@@ -4126,6 +4127,8 @@ Object.assign(CodemanApp.prototype, {
// Same for the text-view toggles: they act on the text this load has not
// fetched yet, and an image or PDF has nothing for them to toggle.
this.filePreviewText = null;
this.filePreviewPage = null;
this.filePreviewPageRefused = false;
this._updateFilePreviewToolbar('none');
// Show overlay with loading state
@@ -4136,6 +4139,27 @@ Object.assign(CodemanApp.prototype, {
const ext = (filePath.split('.').pop() || '').toLowerCase();
// HTML renders as a page: the server mints a sandboxed /html-view URL for the
// file's directory (html-view-routes.ts), so relative CSS, scripts and images
// resolve, and the page runs in an opaque origin that cannot reach Codeman.
// The Page pill switches to the source view below (with its Edit button), and
// a refused mint (remote case, broad folder, hidden directory) falls through
// to that source view on its own.
if (!attachmentId && rendersAsPage(filePath) && this._filePreviewPref('htmlRendered')) {
const pageUrl = await this._mintHtmlViewUrl(filePath, sessionId);
if (pageUrl) {
footerEl.textContent = 'HTML';
this.filePreviewPage = { filePath, sessionId };
this.filePreviewDetachUrl = pageUrl;
if (detachBtn) detachBtn.hidden = false;
bodyEl.innerHTML = `<iframe src="${escapeHtml(pageUrl)}" title="${escapeHtml(filePath)}" sandbox="allow-scripts allow-forms allow-popups allow-modals allow-downloads"></iframe>`;
this._updateFilePreviewToolbar('page');
return;
}
// The pill would only retry a page the server just refused.
this.filePreviewPageRefused = true;
}
// Out-of-workspace path: mint an attachment id up front. Every branch below
// talks to a workspace-confined route, so without this the image/PDF ones
// render a broken frame and the text one reports a bare "File not found"
@@ -4857,8 +4881,9 @@ Object.assign(CodemanApp.prototype, {
/**
* Show the toggles that apply to the current view: MD for a markdown file in
* either view, Lines/Wrap for the plain-text view only; `'none'` (loading,
* image, media, PDF, edit mode) hides all three.
* either view, Page for an HTML file in either view (`'page'` = rendered),
* Lines/Wrap for the plain-text view only; `'none'` (loading, image, media,
* PDF, edit mode) hides them all.
*/
_updateFilePreviewToolbar(view) {
const set = (id, shown, pressed) => {
@@ -4869,10 +4894,42 @@ Object.assign(CodemanApp.prototype, {
};
const isMarkdown = view !== 'none' && MARKDOWN_EXTS.has(this.filePreviewText?.ext || '');
set('filePreviewMdBtn', isMarkdown, view === 'markdown');
// Page: shown on a rendered HTML page, and on its source view when that view
// can be re-rendered (a bare attachment-card name has no path to mint from).
const text = this.filePreviewText;
const htmlSource =
view === 'text' &&
!this.filePreviewPageRefused &&
rendersAsPage(text?.filePath) &&
(!text.attachmentId || text.filePath.startsWith('/'));
set('filePreviewPageBtn', view === 'page' || htmlSource, view === 'page');
set('filePreviewLinesBtn', view === 'text', this._filePreviewPref('lineNumbers'));
set('filePreviewWrapBtn', view === 'text', this._filePreviewPref('wrap'));
},
/** Mint the sandboxed page URL for an HTML file; null when the server refuses. */
async _mintHtmlViewUrl(filePath, sessionId) {
try {
const res = await fetch(`/api/sessions/${sessionId}/html-view`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: filePath }),
});
const result = await res.json().catch(() => ({}));
return res.ok && result.success ? CodemanBase.url(result.data.url) : null;
} catch {
return null;
}
},
/** Page pill: flip between the rendered page and the source view of an HTML file. */
toggleFilePreviewPage() {
const target = this.filePreviewPage || this.filePreviewText;
if (!target) return;
this._setFilePreviewPref('htmlRendered', !this._filePreviewPref('htmlRendered'));
return this.openFilePreview(target.filePath, target.sessionId);
},
toggleFilePreviewMd() {
this._setFilePreviewPref('mdRendered', !this._filePreviewPref('mdRendered'));
this._renderFilePreviewText();
+4
View File
@@ -101,6 +101,10 @@ self.addEventListener('fetch', (event) => {
if (request.headers.get('upgrade') === 'websocket') return;
if (request.headers.get('accept') === 'text/event-stream') return;
if (request.url.includes('/api/')) return;
// A rendered HTML page is served `no-store` behind a capability that logout
// revokes; caching a popped-out page here would let the offline fallback
// serve it after the capability is gone.
if (request.url.includes('/html-view/')) return;
event.respondWith(
fetch(request)
+7 -1
View File
@@ -2224,7 +2224,13 @@ Object.assign(CodemanApp.prototype, {
self.openFolderGrid(target, sessionId);
return;
}
if (previewsInFileViewer(target) || self._isExternalPreviewPath(target, sessionId)) {
// An HTML file renders as a page in the preview, never as source
// in the log viewer (markdown is in FILE_PREVIEW_EXTENSIONS).
if (
previewsInFileViewer(target) ||
rendersAsPage(target) ||
self._isExternalPreviewPath(target, sessionId)
) {
self.openFilePreview(target, sessionId);
return;
}
+4 -1
View File
@@ -36,6 +36,7 @@ import {
} from '../../user-store.js';
import { getAuthUser, requireAdmin, revokeUserSessions } from '../route-helpers.js';
import { webviewCapabilities } from '../../webview-capabilities.js';
import { htmlViewCapabilities } from '../../html-view-capabilities.js';
import { appendAdminAudit } from '../admin-audit.js';
import { SseEvent } from '../sse-events.js';
import type { AuthPort } from '../ports/auth-port.js';
@@ -183,7 +184,8 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
// Web-tab proxy capabilities are a second credential the cookie purge does not
// touch; a forced logout that left them alive would not be a logout.
const revokedWebviews = webviewCapabilities.revokeOwner(normalizeUsername(username));
audit(req, 'user.logout', username, { revoked, revokedWebviews });
const revokedHtmlViews = htmlViewCapabilities.revokeOwner(normalizeUsername(username));
audit(req, 'user.logout', username, { revoked, revokedWebviews, revokedHtmlViews });
return { success: true, data: { revoked } };
});
@@ -206,6 +208,7 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
}
revokeUserSessions(ctx.authSessions, username);
webviewCapabilities.revokeOwner(normalizeUsername(username));
htmlViewCapabilities.revokeOwner(normalizeUsername(username));
if (deleteSpace) await deleteUserSpace(username);
audit(req, 'user.delete', username, { deleteSpace, killedSessions: owned.length });
ctx.broadcast(SseEvent.AdminUsersChanged, {});
+9 -4
View File
@@ -210,8 +210,8 @@ interface FileBodySource {
cleanup?: () => void;
}
/** Byte source for a LOCAL file (the historical, only path). */
function localFileSource(resolvedPath: string) {
/** Byte source for a LOCAL file (the historical, only path). Shared with html-view-routes.ts. */
export function localFileSource(resolvedPath: string) {
return (range?: { start: number; end: number }): FileBodySource => ({
content: createReadStream(resolvedPath, range ? { start: range.start, end: range.end } : undefined),
});
@@ -231,8 +231,11 @@ function localFileSource(resolvedPath: string) {
* `open` supplies the bytes for the (optional) range, which is what lets a remote
* case reuse this instead of re-implementing the 206/416 contract: same headers,
* same status codes, whether the file sits on this host or behind an ssh pipe.
*
* Exported for the rendered-HTML route (html-view-routes.ts), so a page's own
* `<video>`/`<audio>` can seek as well.
*/
function sendFileBody(
export function sendFileBody(
reply: FastifyReply,
size: number,
rangeHeader: string | string[] | undefined,
@@ -281,7 +284,9 @@ async function serveRawFile(
// would be stored XSS. SVG was always here; HTML/HTM join it now that the text
// family is servable, so widening what can be READ never widened what can RUN.
// The preview overlay reads these through `fetch()`, which ignores the
// disposition, so a clicked .html still shows its source.
// disposition, so the source view of a clicked .html still works. Rendering it
// as a page is html-view-routes.ts's job (sandboxed, opaque origin), never this
// route's.
const markupOnly = extension === 'svg' || extension === 'html' || extension === 'htm';
if (download || markupOnly) {
reply.header(
+286
View File
@@ -0,0 +1,286 @@
/**
* @fileoverview Render a local HTML file (an agent's report, a generated page) as a
* page instead of as source text.
*
* Routes:
* - `POST /api/sessions/:id/html-view` (authenticated) — `{ path }` of an `.html` file;
* returns `{ url }`, an `/html-view/<cap>/<file>` URL for that file.
* - `GET /html-view/:cap/*` (capability) — the HTML file and its sibling assets.
*
* ⚠️ Every response carries `Content-Security-Policy: sandbox ...` WITHOUT
* `allow-same-origin`, so the page runs in an opaque origin whether it is framed or
* opened as a top-level tab: it can run its own scripts but cannot read the Codeman
* document, its cookies or its API. That is what makes rendering safe at all; see
* html-view-capabilities.ts for why the route authenticates on a capability.
* Network access is deliberately OPEN (owner decision, see {@link HTML_VIEW_CSP}).
*
* Path policy: the HTML file itself is admitted like the file preview admits a file
* (inside the session workspace, or outside it under the attachment guard), and the
* capability then serves ONLY the files in that file's own directory tree, filtered
* by the same blocklist plus an asset-extension allowlist and a no-dotfile rule, so
* a page cannot use its own directory to pull a `.env` out through the browser.
* Because that tree is served recursively, minting is refused for an HTML file in a
* hidden directory or directly in a broad root (`/`, home, `/tmp`, `/var/tmp` and
* `$TMPDIR`, the cases and user-space roots, a drive's mount point) or in a
* directory above one, see {@link isBroadHtmlViewRoot}.
*/
import type { FastifyInstance } from 'fastify';
import { realpathSync } from 'node:fs';
import fs from 'node:fs/promises';
import { homedir, tmpdir } from 'node:os';
import { basename, dirname, extname, isAbsolute, relative, resolve, sep } from 'node:path';
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
import { HTML_VIEW_PREFIX, htmlViewCapabilities } from '../../html-view-capabilities.js';
import { CASES_DIR, findSessionOrFail, ownerFor, parseBody, validateSessionFilePath } from '../route-helpers.js';
import { HtmlViewOpenSchema } from '../schemas.js';
import { localFileSource, sendFileBody } from './file-routes.js';
import { getUserSpacesDir } from '../../config/multiuser.js';
import { exceedsDownloadLimit } from '../../config/buffer-limits.js';
import type { SessionPort } from '../ports/index.js';
/** Extensions that open as a rendered page. */
export const HTML_VIEW_EXTENSIONS: ReadonlySet<string> = new Set(['html', 'htm']);
/** What a rendered page may load from its own directory, with the type it is served as. */
const ASSET_TYPES: Record<string, string> = {
html: 'text/html; charset=utf-8',
htm: 'text/html; charset=utf-8',
css: 'text/css; charset=utf-8',
js: 'text/javascript; charset=utf-8',
mjs: 'text/javascript; charset=utf-8',
json: 'application/json; charset=utf-8',
map: 'application/json; charset=utf-8',
txt: 'text/plain; charset=utf-8',
csv: 'text/csv; charset=utf-8',
md: 'text/plain; charset=utf-8',
svg: 'image/svg+xml',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
avif: 'image/avif',
ico: 'image/x-icon',
bmp: 'image/bmp',
woff: 'font/woff',
woff2: 'font/woff2',
ttf: 'font/ttf',
otf: 'font/otf',
wasm: 'application/wasm',
mp4: 'video/mp4',
webm: 'video/webm',
mp3: 'audio/mpeg',
wav: 'audio/wav',
pdf: 'application/pdf',
};
/**
* The whole Content-Security-Policy every response is served under: the `sandbox`
* directive and nothing else. It REPLACES Codeman's own policy (`default-src 'self'`
* and the rest, set earlier by registerSecurityHeaders). No `allow-same-origin`:
* that one token is the difference between "a page" and "a page that can drive
* Codeman".
*
* ⚠️ Network access is OPEN, by the owner's decision (2026-10-13, #547): a rendered
* page behaves like a file opened from disk in a browser, so there is deliberately
* no fetch directive (`default-src`, `script-src`, `connect-src`, ...). It loads
* scripts, styles, fonts, images and media from anywhere (an agent's dashboard
* pulling Chart.js from a CDN) and may fetch, XHR, open WebSockets, send beacons and
* post forms anywhere. What confines it is the opaque origin (no Codeman cookies, no
* Codeman API) and the capability serving only its own folder. Do not add a fetch
* directive, or `allow-same-origin`, without the owner. test/routes/html-view-routes.test.ts
* pins the exact value.
*/
export const HTML_VIEW_CSP = 'sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads';
function extensionOf(path: string): string {
return extname(path).toLowerCase().replace(/^\./, '');
}
/** realpath when the path exists, else the plain resolved path. */
function canonical(path: string): string {
try {
return realpathSync(path);
} catch {
return resolve(path);
}
}
/**
* The shared temp dirs, named literally: `os.tmpdir()` is `$TMPDIR` whenever that
* is set, and macOS sets it in every terminal session (`/var/folders/.../T`), so
* a server started from one would otherwise never see `/tmp` as broad.
*/
const SHARED_TEMP_DIRS = ['/tmp', '/var/tmp'];
/**
* Where drives are mounted. A page at a drive's root (WSL's `/mnt/d/report.html`)
* would be handed the whole drive, so the mount parent and each mount point in it
* count as broad.
*/
const MOUNT_PARENTS = ['/mnt', '/media', '/Volumes'];
/**
* Whether a directory is too broad to hand to a page as its asset root. The
* capability serves the HTML file's directory RECURSIVELY to the page's own
* scripts, so `/tmp/report.html` would expose every other session's scratch
* output under `/tmp`, and `~/report.html` the home tree. Refuse those roots
* (and the cases / user-space roots, which hold every workspace) outright, and
* any directory ABOVE one of them (`/home`, `/Users`, macOS's `/private`), since
* serving it recursively serves the root below it too.
*/
export function isBroadHtmlViewRoot(dir: string): boolean {
const target = canonical(dir);
const userSpaces = canonical(getUserSpacesDir());
// canonical() also maps the literal temp dirs to macOS's `/private/tmp` and `/private/var/tmp`.
const broad = [sep, homedir(), tmpdir(), ...SHARED_TEMP_DIRS, CASES_DIR, userSpaces].map(canonical);
if (broad.some((root) => isUnderTree(root, target))) return true;
for (const parent of MOUNT_PARENTS.map(canonical)) {
if (target === parent || dirname(target) === parent) return true;
}
// A user's own space (`<spaces>/<user>`) and its cases dir hold every workspace of that user.
if (dirname(target) === userSpaces) return true;
return basename(target) === 'cases' && dirname(dirname(target)) === userSpaces;
}
export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort): void {
app.post('/api/sessions/:id/html-view', async (req, reply) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
const body = parseBody(HtmlViewOpenSchema, req.body ?? {});
if (session.remote) {
reply
.code(400)
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'HTML pages in a remote (SSH) case cannot be rendered'));
return;
}
if (!HTML_VIEW_EXTENSIONS.has(extensionOf(body.path))) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Not an HTML file'));
return;
}
// Inside the workspace: the same check every file route uses. Outside it: the
// attachment guard, exactly as an external file preview is admitted.
let resolvedPath: string;
const inWorkspace = validateSessionFilePath(session.workingDir, body.path);
if (inWorkspace) {
resolvedPath = inWorkspace.resolvedPath;
} else {
if (!isAbsolute(body.path)) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return;
}
try {
resolvedPath = await fs.realpath(body.path);
} catch {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return;
}
const guard = await loadAttachmentGuardConfig();
if (guard.confineToWorkspace || isBlockedAttachmentPath(resolvedPath, guard.blockedTrees)) {
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'));
return;
}
}
const stat = await fs.stat(resolvedPath).catch(() => null);
if (!stat?.isFile()) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return;
}
// The page URL names the RESOLVED file, so it has to be HTML too: a
// `report.html -> notes.txt` link would otherwise mint a page for a text file.
if (!HTML_VIEW_EXTENSIONS.has(extensionOf(resolvedPath))) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Not an HTML file'));
return;
}
// The serving route never descends into a dot segment below the capability
// root; the HTML file's own path gets the same rule, or `.cache/r.html` would
// mint a capability over a hidden directory and serve its `token.json`. Inside
// the workspace the rule covers the part below the workspace root, which is
// all the page's tree can reach, so a workspace that itself lives under a
// hidden folder (`~/.local/src/app`) can still render its pages.
const checkedPath = inWorkspace ? inWorkspace.relativePath : resolvedPath;
if (checkedPath.split(sep).some((segment) => segment.startsWith('.'))) {
reply
.code(403)
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'HTML files in hidden directories cannot be rendered'));
return;
}
if (isBroadHtmlViewRoot(dirname(resolvedPath))) {
reply
.code(403)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'This folder is too broad to render a page from; move the HTML file into its own folder'
)
);
return;
}
const cap = htmlViewCapabilities.mint(dirname(resolvedPath), ownerFor(req), !inWorkspace);
return {
success: true,
data: { url: `${HTML_VIEW_PREFIX}/${cap}/${encodeURIComponent(basename(resolvedPath))}` },
};
});
app.get(`${HTML_VIEW_PREFIX}/:cap/*`, async (req, reply) => {
const { cap, '*': rest } = req.params as { cap: string; '*': string };
const record = htmlViewCapabilities.resolve(cap);
const notFound = () => reply.code(404).header('Content-Type', 'text/plain; charset=utf-8').send('Not found');
if (!record) return notFound();
// Fastify has already percent-decoded the wildcard. Hidden files and
// directories are never served, whatever their extension. This is the cheap
// reject on the requested name; the same two rules run again on the resolved
// path below.
const segments = (rest || '').split('/').filter(Boolean);
if (segments.length === 0 || segments.some((s) => s.startsWith('.'))) return notFound();
if (!ASSET_TYPES[extensionOf(segments[segments.length - 1])]) return notFound();
let resolvedPath: string;
try {
resolvedPath = await fs.realpath(resolve(record.rootDir, segments.join(sep)));
} catch {
return notFound();
}
// Symlinks are resolved above, so this is the real containment check, and the
// dot and extension rules have to hold for where a link LANDS as well: a
// `cfg.json -> .git/config` inside the page's folder passes both on its own name.
if (!isUnderTree(resolvedPath, record.rootDir)) return notFound();
const landed = relative(record.rootDir, resolvedPath).split(sep);
if (landed.some((s) => s.startsWith('.'))) return notFound();
// Typed by what the bytes are, the resolved name.
const contentType = ASSET_TYPES[extensionOf(resolvedPath)];
if (!contentType) return notFound();
const guard = await loadAttachmentGuardConfig();
if (isBlockedAttachmentPath(resolvedPath, guard.blockedTrees)) return notFound();
// Re-checked per request, like the attachment serve path (file-routes.ts): a
// page minted from outside the workspace before an admin switched confinement
// on stops serving once it is on, instead of living out its capability.
if (record.outsideWorkspace && guard.confineToWorkspace) return notFound();
const stat = await fs.stat(resolvedPath).catch(() => null);
if (!stat?.isFile() || exceedsDownloadLimit(stat.size)) return notFound();
reply.header('Content-Security-Policy', HTML_VIEW_CSP);
reply.header('Content-Type', contentType);
reply.header('Cache-Control', 'no-store');
reply.header('Referrer-Policy', 'no-referrer');
// The sandboxed page is opaque-origin, so even its fetch() of a sibling
// `data.json` is CORS-checked with `Origin: null`. `*` without credentials is
// enough, and widens nothing: these bytes are already readable by anyone
// holding the capability URL.
reply.header('Access-Control-Allow-Origin', '*');
// The file routes' body sender: Content-Length, and `Range` answered with 206
// (or 416), so a page's own <video>/<audio> can seek. It hijacks the reply, and
// carries every header set above across to it.
sendFileBody(reply, stat.size, req.headers.range, localFileSource(resolvedPath));
return reply;
});
}
+1
View File
@@ -6,6 +6,7 @@ export { registerPushRoutes } from './push-routes.js';
export { registerTeamRoutes } from './team-routes.js';
export { registerMuxRoutes } from './mux-routes.js';
export { registerFileRoutes } from './file-routes.js';
export { registerHtmlViewRoutes } from './html-view-routes.js';
export { registerScheduledRoutes } from './scheduled-routes.js';
export { registerCronRoutes } from './cron-routes.js';
export { registerSystemRoutes } from './system-routes.js';
+2
View File
@@ -42,6 +42,7 @@ import {
import type { PaneCaptureOptions } from '../../mux-interface.js';
import { SseEvent } from '../sse-events.js';
import { webviewCapabilities } from '../../webview-capabilities.js';
import { htmlViewCapabilities } from '../../html-view-capabilities.js';
import {
CreateSessionSchema,
SessionNameSchema,
@@ -996,6 +997,7 @@ export function registerSessionRoutes(
// logout has to retire them too or every dashboard URL opened during this
// login keeps relaying without one (WebviewCapabilityStore.revokeOwner).
webviewCapabilities.revokeOwner(ownerFor(req));
htmlViewCapabilities.revokeOwner(ownerFor(req));
reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' });
return {};
});
+18
View File
@@ -127,6 +127,24 @@ export const FileWriteSchema = z
})
.strict();
/**
* Body validation for `POST /api/sessions/:id/html-view` (render a local HTML file
* as a sandboxed page). `path` is absolute or relative to the session workspace;
* the extension, containment, guard and broad-root checks are the route's
* (html-view-routes.ts), after parsing.
*/
export const HtmlViewOpenSchema = z
.object({
path: z
.string({ error: 'Missing path parameter' })
.min(1, 'Missing path parameter')
.max(4096)
.refine((p) => !p.includes('\0') && !p.includes('\n') && !p.includes('\r'), {
message: 'Invalid path',
}),
})
.strict();
/**
* The run-mode ids the API currently accepts: every ENABLED registry entry.
*
+2
View File
@@ -181,6 +181,7 @@ import {
registerTeamRoutes,
registerMuxRoutes,
registerFileRoutes,
registerHtmlViewRoutes,
registerScheduledRoutes,
registerHookEventRoutes,
registerApprovalRoutes,
@@ -1142,6 +1143,7 @@ export class WebServer extends EventEmitter {
registerTeamRoutes(this.app, ctx);
registerMuxRoutes(this.app, ctx);
registerFileRoutes(this.app, ctx);
registerHtmlViewRoutes(this.app, ctx);
registerScheduledRoutes(this.app, ctx);
registerHookEventRoutes(this.app, ctx);
registerApprovalRoutes(this.app, ctx);