From 5e83cd38854eef1644ff4467da683b36f5d93132 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 13 Oct 2026 18:56:58 +0200 Subject: [PATCH] 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) --- docs/api-reference.md | 5 +++-- src/web/routes/file-routes.ts | 2 +- src/web/routes/html-view-routes.ts | 21 ++++++++++++++++----- test/routes/html-view-routes.test.ts | 18 ++++++++++++++++++ 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index f823b1b4..db7ac4d2 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -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": "" }` (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": "" }` (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//` 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 diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index cf0a1513..395e6af2 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -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'; /** diff --git a/src/web/routes/html-view-routes.ts b/src/web/routes/html-view-routes.ts index 45b9a981..34496136 100644 --- a/src/web/routes/html-view-routes.ts +++ b/src/web/routes/html-view-routes.ts @@ -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; diff --git a/test/routes/html-view-routes.test.ts b/test/routes/html-view-routes.test.ts index 54d24e06..475121c2 100644 --- a/test/routes/html-view-routes.test.ts +++ b/test/routes/html-view-routes.test.ts @@ -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'), '

home

'); + 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 });