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

+3 -2
View File
@@ -605,8 +605,9 @@ bucket. The response shape is unchanged (`path`, `filename`).
An `.html`/`.htm` file renders as a page in a sandbox, in two steps
(`src/web/routes/html-view-routes.ts`).
**`POST /api/v1/sessions/:id/html-view`** with `{ "path": "<file>" }` (absolute, or
relative to the session's workspace; a non-empty string of at most 4096 characters
**`POST /api/v1/sessions/:id/html-view`** with `{ "path": "<file>" }` (absolute,
relative to the session's workspace, or `~/` for this host's home, which a Docker
case refuses with `400` since its `~` is the container's; a non-empty string of at most 4096 characters
without NUL or line breaks, and no other keys) answers `data: { url }`, a
`/html-view/<cap>/<file>` URL. The capability grants GET access to the files under
that file's own folder, so a page's relative CSS, scripts, images and data files
+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;
+18
View File
@@ -66,6 +66,24 @@ describe('html-view routes', () => {
expect(res.statusCode).toBe(200);
});
it('expands a ~/ path against this host home, as the attachment route does', async () => {
// test/setup.ts points HOME at a temp dir, so this never touches the real home.
// The terminal passes a `~/` link through unchanged; only the server knows the home.
const site = join(homedir(), 'html-view-tilde', 'site');
mkdirSync(site, { recursive: true });
writeFileSync(join(site, 'page.html'), '<h1>home</h1>');
try {
const res = await open('~/html-view-tilde/site/page.html');
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).data.url).toMatch(/^\/html-view\/[A-Za-z0-9_-]{16,}\/page\.html$/);
// A Docker case's `~` is the container's home, never this host's.
(harness.ctx.sessions.get(sid) as { docker?: unknown }).docker = { caseName: 'c' };
expect((await open('~/html-view-tilde/site/page.html')).statusCode).toBe(400);
} finally {
rmSync(join(homedir(), 'html-view-tilde'), { recursive: true, force: true });
}
});
it('validates the body', async () => {
const post = (payload: unknown) =>
harness.app.inject({ method: 'POST', url: `/api/sessions/${sid}/html-view`, payload: payload as object });