fix(html-view): a ~/ HTML link renders as a page too (#547/#604 integration)

#604 makes `~/` links clickable and passes them through unchanged, since only
the server knows the session host's home. The html-view mint read `~/x.html`
as workspace-relative and answered 404, so the preview fell back to the
source view. It now expands `~/` against this host's home, as the attachment
route does, and refuses it in a Docker case (that `~` is the container's)
with the same message as the attachment and folder routes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-13 18:56:58 +02:00
1 parent 1172ef56f8
commit 5e83cd3885
4 files changed
+38 -8

No files matched your search

+1 -1
View File
@@ -580,7 +580,7 @@ interface SessionFileScope {
* host's home would open a different file than the one the agent printed, so
* the form is refused instead of guessed.
*/
const DOCKER_HOME_PATH_ERROR =
export const DOCKER_HOME_PATH_ERROR =
'A ~/ path inside a Docker case is the container home, which this server cannot read; use the absolute path';
/**
+16 -5
View File
@@ -29,13 +29,13 @@ 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 { basename, dirname, extname, isAbsolute, join, 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 { DOCKER_HOME_PATH_ERROR, 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';
@@ -160,20 +160,31 @@ export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort):
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Not an HTML file'));
return;
}
// A `~/` link (the terminal passes a home-relative path through, since only
// the server knows the home) is this host's home, as the attachment route
// expands it. A Docker case's `~` is the container's, so it is refused there.
let requestedPath = body.path;
if (requestedPath.startsWith('~/')) {
if (session.docker) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, DOCKER_HOME_PATH_ERROR));
return;
}
requestedPath = join(homedir(), requestedPath.slice(2));
}
// 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);
const inWorkspace = validateSessionFilePath(session.workingDir, requestedPath);
if (inWorkspace) {
resolvedPath = inWorkspace.resolvedPath;
} else {
if (!isAbsolute(body.path)) {
if (!isAbsolute(requestedPath)) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return;
}
try {
resolvedPath = await fs.realpath(body.path);
resolvedPath = await fs.realpath(requestedPath);
} catch {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return;